From f9f984a8f4b61389b08d3845d76e22c662b8ceeb Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Thu, 22 Jan 2026 07:06:00 -0500 Subject: [PATCH 01/36] fix(db): Remove redundant migration and fix pgvector schema handling (#11822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changes šŸ—ļø This PR includes two database migration fixes: #### 1. Remove redundant Supabase extensions migration Removes the `20260112173500_add_supabase_extensions_to_platform_schema` migration which was attempting to manage Supabase-provided extensions and schemas. **What was removed:** - Migration that created extensions (pgcrypto, uuid-ossp, pg_stat_statements, pg_net, pgjwt, pg_graphql, pgsodium, supabase_vault) - Schema creation for these extensions **Why it was removed:** - These extensions and schemas are pre-installed and managed by Supabase automatically - The migration was redundant and could cause schema drift warnings - Attempting to manage Supabase-owned resources in our migrations is an anti-pattern #### 2. Fix pgvector extension schema handling Improves the `20260109181714_add_docs_embedding` migration to handle cases where pgvector exists in the wrong schema. **Problem:** - If pgvector was previously installed in `public` schema, `CREATE EXTENSION IF NOT EXISTS` would succeed but not actually install it in the `platform` schema - This causes `type "vector" does not exist` errors because the type isn't in the search_path **Solution:** - Detect if vector extension exists in a different schema than the current one - Drop it with CASCADE and reinstall in the correct schema (platform) - Use dynamic SQL with `EXECUTE format()` to explicitly specify the target schema - Split exception handling: catch errors during removal, but let installation fail naturally with clear PostgreSQL errors **Impact:** - No functional changes - Supabase continues to provide extensions as before - pgvector now correctly installs in the platform schema - Cleaner migration history - Prevents schema-related errors ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verified migrations run successfully without the redundant file - [x] Confirmed Supabase extensions are still available - [x] Tested pgvector migration handles wrong-schema scenario - [x] No schema drift warnings #### For configuration changes: - [x] .env.default is updated or already compatible with my changes - [x] docker-compose.yml is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) - N/A - No configuration changes required --- .../migration.sql | 33 +++++++-- .../migration.sql | 71 ------------------- 2 files changed, 29 insertions(+), 75 deletions(-) delete mode 100644 autogpt_platform/backend/migrations/20260112173500_add_supabase_extensions_to_platform_schema/migration.sql diff --git a/autogpt_platform/backend/migrations/20260109181714_add_docs_embedding/migration.sql b/autogpt_platform/backend/migrations/20260109181714_add_docs_embedding/migration.sql index 855fe36933..a839070a28 100644 --- a/autogpt_platform/backend/migrations/20260109181714_add_docs_embedding/migration.sql +++ b/autogpt_platform/backend/migrations/20260109181714_add_docs_embedding/migration.sql @@ -1,12 +1,37 @@ -- CreateExtension -- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first --- Creates extension in current schema (determined by search_path from DATABASE_URL ?schema= param) +-- Ensures vector extension is in the current schema (from DATABASE_URL ?schema= param) +-- If it exists in a different schema (e.g., public), we drop and recreate it in the current schema -- This ensures vector type is in the same schema as tables, making ::vector work without explicit qualification DO $$ +DECLARE + current_schema_name text; + vector_schema text; BEGIN - CREATE EXTENSION IF NOT EXISTS "vector"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'vector extension not available or already exists, skipping'; + -- Get the current schema from search_path + SELECT current_schema() INTO current_schema_name; + + -- Check if vector extension exists and which schema it's in + SELECT n.nspname INTO vector_schema + FROM pg_extension e + JOIN pg_namespace n ON e.extnamespace = n.oid + WHERE e.extname = 'vector'; + + -- Handle removal if in wrong schema + IF vector_schema IS NOT NULL AND vector_schema != current_schema_name THEN + BEGIN + -- Vector exists in a different schema, drop it first + RAISE WARNING 'pgvector found in schema "%" but need it in "%". Dropping and reinstalling...', + vector_schema, current_schema_name; + EXECUTE 'DROP EXTENSION IF EXISTS vector CASCADE'; + EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'Failed to drop pgvector from schema "%": %. You may need to drop it manually.', + vector_schema, SQLERRM; + END; + END IF; + + -- Create extension in current schema (let it fail naturally if not available) + EXECUTE format('CREATE EXTENSION IF NOT EXISTS vector SCHEMA %I', current_schema_name); END $$; -- CreateEnum diff --git a/autogpt_platform/backend/migrations/20260112173500_add_supabase_extensions_to_platform_schema/migration.sql b/autogpt_platform/backend/migrations/20260112173500_add_supabase_extensions_to_platform_schema/migration.sql deleted file mode 100644 index ca91bc5cab..0000000000 --- a/autogpt_platform/backend/migrations/20260112173500_add_supabase_extensions_to_platform_schema/migration.sql +++ /dev/null @@ -1,71 +0,0 @@ --- Acknowledge Supabase-managed extensions to prevent drift warnings --- These extensions are pre-installed by Supabase in specific schemas --- This migration ensures they exist where available (Supabase) or skips gracefully (CI) - --- Create schemas (safe in both CI and Supabase) -CREATE SCHEMA IF NOT EXISTS "extensions"; - --- Extensions that exist in both CI and Supabase -DO $$ -BEGIN - CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'pgcrypto extension not available, skipping'; -END $$; - -DO $$ -BEGIN - CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'uuid-ossp extension not available, skipping'; -END $$; - --- Supabase-specific extensions (skip gracefully in CI) -DO $$ -BEGIN - CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'pg_stat_statements extension not available, skipping'; -END $$; - -DO $$ -BEGIN - CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'pg_net extension not available, skipping'; -END $$; - -DO $$ -BEGIN - CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'pgjwt extension not available, skipping'; -END $$; - -DO $$ -BEGIN - CREATE SCHEMA IF NOT EXISTS "graphql"; - CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'pg_graphql extension not available, skipping'; -END $$; - -DO $$ -BEGIN - CREATE SCHEMA IF NOT EXISTS "pgsodium"; - CREATE EXTENSION IF NOT EXISTS "pgsodium" WITH SCHEMA "pgsodium"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'pgsodium extension not available, skipping'; -END $$; - -DO $$ -BEGIN - CREATE SCHEMA IF NOT EXISTS "vault"; - CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault"; -EXCEPTION WHEN OTHERS THEN - RAISE NOTICE 'supabase_vault extension not available, skipping'; -END $$; - - --- Return to platform -CREATE SCHEMA IF NOT EXISTS "platform"; \ No newline at end of file From 90466908a8e2a1626d8a2455b561f0172df68d6a Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Fri, 23 Jan 2026 00:18:16 -0600 Subject: [PATCH 02/36] =?UTF-8?q?refactor(docs):=20restructure=20platform?= =?UTF-8?q?=20docs=20for=20GitBook=20and=20remove=20MkDo=E2=80=A6=20(#1182?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit we met some reality when merging into the docs site but this fixes it ### Changes šŸ—ļø updates paths, adds some guides update to match reality ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] deploy it and validate --- > [!NOTE] > Aligns block integrations documentation with GitBook. > > - Changes generator default output to `docs/integrations/block-integrations` and writes overview `README.md` and `SUMMARY.md` at `docs/integrations/` > - Adds GitBook frontmatter and hint syntax to overview; prefixes block links with `block-integrations/` > - Introduces `generate_summary_md` to build GitBook navigation (including optional `guides/`) > - Preserves per-block manual sections and adds optional `extras` + file-level `additional_content` > - Updates sync checker to validate parent `README.md` and `SUMMARY.md` > - Rewrites `docs/integrations/README.md` with GitBook frontmatter and updated links; adds `docs/integrations/SUMMARY.md` > - Adds new guides: `guides/llm-providers.md`, `guides/voice-providers.md` > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fdb7ff81110afe83e1c4e59970ea1395f7f4b7a1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: bobby.gaffin --- .../backend/scripts/generate_block_docs.py | 168 ++- .../.gitbook/assets/Ollama-Add-Prompts.png | Bin 0 -> 118022 bytes .../.gitbook/assets/Ollama-Output.png | Bin 0 -> 29918 bytes .../.gitbook/assets/Ollama-Remote-Host.png | Bin 0 -> 6128 bytes .../.gitbook/assets/Ollama-Select-Llama32.png | Bin 0 -> 83379 bytes .../.gitbook/assets/Select-AI-block.png | Bin 0 -> 119144 bytes .../.gitbook/assets/e2b-dashboard.png | Bin 0 -> 515634 bytes .../.gitbook/assets/e2b-log-url.png | Bin 0 -> 43687 bytes .../.gitbook/assets/e2b-new-tag.png | Bin 0 -> 47736 bytes .../.gitbook/assets/e2b-tag-button.png | Bin 0 -> 20635 bytes .../.gitbook/assets/get-repo-dialog.png | Bin 0 -> 69845 bytes docs/integrations/README.md | 956 +++++++++--------- docs/integrations/SUMMARY.md | 133 +++ .../{ => block-integrations}/ai_condition.md | 0 .../ai_shortform_video_block.md | 0 .../airtable/bases.md | 0 .../airtable/records.md | 0 .../airtable/schema.md | 0 .../airtable/triggers.md | 0 .../apollo/organization.md | 0 .../{ => block-integrations}/apollo/people.md | 0 .../{ => block-integrations}/apollo/person.md | 0 .../ayrshare/post_to_bluesky.md | 0 .../ayrshare/post_to_facebook.md | 0 .../ayrshare/post_to_gmb.md | 0 .../ayrshare/post_to_instagram.md | 0 .../ayrshare/post_to_linkedin.md | 0 .../ayrshare/post_to_pinterest.md | 0 .../ayrshare/post_to_reddit.md | 0 .../ayrshare/post_to_snapchat.md | 0 .../ayrshare/post_to_telegram.md | 0 .../ayrshare/post_to_threads.md | 0 .../ayrshare/post_to_tiktok.md | 0 .../ayrshare/post_to_x.md | 0 .../ayrshare/post_to_youtube.md | 0 .../{ => block-integrations}/baas/bots.md | 0 .../bannerbear/text_overlay.md | 0 .../{ => block-integrations}/basic.md | 0 .../{ => block-integrations}/branching.md | 0 .../compass/triggers.md | 0 .../{ => block-integrations}/csv.md | 0 .../{ => block-integrations}/data.md | 0 .../dataforseo/keyword_suggestions.md | 0 .../dataforseo/related_keywords.md | 0 .../{ => block-integrations}/decoder_block.md | 0 .../{ => block-integrations}/discord.md | 0 .../discord/bot_blocks.md | 0 .../discord/oauth_blocks.md | 0 .../{ => block-integrations}/email_block.md | 0 .../enrichlayer/linkedin.md | 0 .../{ => block-integrations}/exa/answers.md | 0 .../exa/code_context.md | 0 .../{ => block-integrations}/exa/contents.md | 0 .../{ => block-integrations}/exa/research.md | 0 .../{ => block-integrations}/exa/search.md | 0 .../{ => block-integrations}/exa/similar.md | 0 .../exa/webhook_blocks.md | 0 .../{ => block-integrations}/exa/websets.md | 0 .../exa/websets_enrichment.md | 0 .../exa/websets_import_export.md | 0 .../exa/websets_items.md | 0 .../exa/websets_monitor.md | 0 .../exa/websets_polling.md | 0 .../exa/websets_search.md | 0 .../fal/ai_video_generator.md | 0 .../firecrawl/crawl.md | 0 .../firecrawl/extract.md | 0 .../{ => block-integrations}/firecrawl/map.md | 0 .../firecrawl/scrape.md | 0 .../firecrawl/search.md | 0 .../{ => block-integrations}/flux_kontext.md | 0 .../generic_webhook/triggers.md | 0 .../{ => block-integrations}/github/checks.md | 0 .../{ => block-integrations}/github/ci.md | 0 .../{ => block-integrations}/github/issues.md | 0 .../github/pull_requests.md | 0 .../{ => block-integrations}/github/repo.md | 0 .../github/reviews.md | 0 .../github/statuses.md | 0 .../github/triggers.md | 0 .../google/calendar.md | 0 .../{ => block-integrations}/google/docs.md | 0 .../{ => block-integrations}/google/gmail.md | 0 .../{ => block-integrations}/google/sheet.md | 0 .../{ => block-integrations}/google/sheets.md | 0 .../{ => block-integrations}/google_maps.md | 0 .../{ => block-integrations}/http.md | 0 .../hubspot/company.md | 0 .../hubspot/contact.md | 0 .../hubspot/engagement.md | 0 .../{ => block-integrations}/ideogram.md | 0 .../{ => block-integrations}/iteration.md | 0 .../{ => block-integrations}/jina/chunking.md | 0 .../jina/embeddings.md | 0 .../jina/fact_checker.md | 0 .../{ => block-integrations}/jina/search.md | 0 .../linear/comment.md | 0 .../{ => block-integrations}/linear/issues.md | 0 .../linear/projects.md | 0 .../{ => block-integrations}/llm.md | 0 .../{ => block-integrations}/logic.md | 0 .../{ => block-integrations}/maths.md | 0 .../{ => block-integrations}/medium.md | 0 .../{ => block-integrations}/misc.md | 0 .../{ => block-integrations}/multimedia.md | 0 .../notion/create_page.md | 0 .../notion/read_database.md | 0 .../notion/read_page.md | 0 .../notion/read_page_markdown.md | 0 .../{ => block-integrations}/notion/search.md | 0 .../nvidia/deepfake.md | 0 .../{ => block-integrations}/reddit.md | 0 .../replicate/flux_advanced.md | 0 .../replicate/replicate_block.md | 0 .../replicate_flux_advanced.md | 0 .../{ => block-integrations}/rss.md | 0 .../{ => block-integrations}/sampling.md | 0 .../{ => block-integrations}/search.md | 0 .../slant3d/filament.md | 0 .../{ => block-integrations}/slant3d/order.md | 0 .../slant3d/slicing.md | 0 .../slant3d/webhook.md | 0 .../smartlead/campaign.md | 0 .../stagehand/blocks.md | 0 .../system/library_operations.md | 0 .../system/store_operations.md | 0 .../{ => block-integrations}/talking_head.md | 0 .../{ => block-integrations}/text.md | 0 .../text_to_speech_block.md | 0 .../{ => block-integrations}/time_blocks.md | 0 .../{ => block-integrations}/todoist.md | 0 .../todoist/comments.md | 0 .../todoist/labels.md | 0 .../todoist/projects.md | 0 .../todoist/sections.md | 0 .../{ => block-integrations}/todoist/tasks.md | 0 .../twitter/blocks.md | 0 .../twitter/bookmark.md | 0 .../twitter/follows.md | 0 .../{ => block-integrations}/twitter/hide.md | 0 .../{ => block-integrations}/twitter/like.md | 0 .../twitter/list_follows.md | 0 .../twitter/list_lookup.md | 0 .../twitter/list_members.md | 0 .../twitter/list_tweets_lookup.md | 0 .../twitter/manage.md | 0 .../twitter/manage_lists.md | 0 .../{ => block-integrations}/twitter/mutes.md | 0 .../twitter/pinned_lists.md | 0 .../{ => block-integrations}/twitter/quote.md | 0 .../twitter/retweet.md | 0 .../twitter/search_spaces.md | 0 .../twitter/spaces_lookup.md | 0 .../twitter/timeline.md | 0 .../twitter/tweet_lookup.md | 0 .../twitter/twitter.md | 0 .../twitter/user_lookup.md | 0 .../wolfram/llm_api.md | 0 .../{ => block-integrations}/youtube.md | 0 .../zerobounce/validate_emails.md | 0 docs/integrations/guides/llm-providers.md | 16 + docs/integrations/guides/voice-providers.md | 22 + 162 files changed, 810 insertions(+), 485 deletions(-) create mode 100644 docs/integrations/.gitbook/assets/Ollama-Add-Prompts.png create mode 100644 docs/integrations/.gitbook/assets/Ollama-Output.png create mode 100644 docs/integrations/.gitbook/assets/Ollama-Remote-Host.png create mode 100644 docs/integrations/.gitbook/assets/Ollama-Select-Llama32.png create mode 100644 docs/integrations/.gitbook/assets/Select-AI-block.png create mode 100644 docs/integrations/.gitbook/assets/e2b-dashboard.png create mode 100644 docs/integrations/.gitbook/assets/e2b-log-url.png create mode 100644 docs/integrations/.gitbook/assets/e2b-new-tag.png create mode 100644 docs/integrations/.gitbook/assets/e2b-tag-button.png create mode 100644 docs/integrations/.gitbook/assets/get-repo-dialog.png create mode 100644 docs/integrations/SUMMARY.md rename docs/integrations/{ => block-integrations}/ai_condition.md (100%) rename docs/integrations/{ => block-integrations}/ai_shortform_video_block.md (100%) rename docs/integrations/{ => block-integrations}/airtable/bases.md (100%) rename docs/integrations/{ => block-integrations}/airtable/records.md (100%) rename docs/integrations/{ => block-integrations}/airtable/schema.md (100%) rename docs/integrations/{ => block-integrations}/airtable/triggers.md (100%) rename docs/integrations/{ => block-integrations}/apollo/organization.md (100%) rename docs/integrations/{ => block-integrations}/apollo/people.md (100%) rename docs/integrations/{ => block-integrations}/apollo/person.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_bluesky.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_facebook.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_gmb.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_instagram.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_linkedin.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_pinterest.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_reddit.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_snapchat.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_telegram.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_threads.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_tiktok.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_x.md (100%) rename docs/integrations/{ => block-integrations}/ayrshare/post_to_youtube.md (100%) rename docs/integrations/{ => block-integrations}/baas/bots.md (100%) rename docs/integrations/{ => block-integrations}/bannerbear/text_overlay.md (100%) rename docs/integrations/{ => block-integrations}/basic.md (100%) rename docs/integrations/{ => block-integrations}/branching.md (100%) rename docs/integrations/{ => block-integrations}/compass/triggers.md (100%) rename docs/integrations/{ => block-integrations}/csv.md (100%) rename docs/integrations/{ => block-integrations}/data.md (100%) rename docs/integrations/{ => block-integrations}/dataforseo/keyword_suggestions.md (100%) rename docs/integrations/{ => block-integrations}/dataforseo/related_keywords.md (100%) rename docs/integrations/{ => block-integrations}/decoder_block.md (100%) rename docs/integrations/{ => block-integrations}/discord.md (100%) rename docs/integrations/{ => block-integrations}/discord/bot_blocks.md (100%) rename docs/integrations/{ => block-integrations}/discord/oauth_blocks.md (100%) rename docs/integrations/{ => block-integrations}/email_block.md (100%) rename docs/integrations/{ => block-integrations}/enrichlayer/linkedin.md (100%) rename docs/integrations/{ => block-integrations}/exa/answers.md (100%) rename docs/integrations/{ => block-integrations}/exa/code_context.md (100%) rename docs/integrations/{ => block-integrations}/exa/contents.md (100%) rename docs/integrations/{ => block-integrations}/exa/research.md (100%) rename docs/integrations/{ => block-integrations}/exa/search.md (100%) rename docs/integrations/{ => block-integrations}/exa/similar.md (100%) rename docs/integrations/{ => block-integrations}/exa/webhook_blocks.md (100%) rename docs/integrations/{ => block-integrations}/exa/websets.md (100%) rename docs/integrations/{ => block-integrations}/exa/websets_enrichment.md (100%) rename docs/integrations/{ => block-integrations}/exa/websets_import_export.md (100%) rename docs/integrations/{ => block-integrations}/exa/websets_items.md (100%) rename docs/integrations/{ => block-integrations}/exa/websets_monitor.md (100%) rename docs/integrations/{ => block-integrations}/exa/websets_polling.md (100%) rename docs/integrations/{ => block-integrations}/exa/websets_search.md (100%) rename docs/integrations/{ => block-integrations}/fal/ai_video_generator.md (100%) rename docs/integrations/{ => block-integrations}/firecrawl/crawl.md (100%) rename docs/integrations/{ => block-integrations}/firecrawl/extract.md (100%) rename docs/integrations/{ => block-integrations}/firecrawl/map.md (100%) rename docs/integrations/{ => block-integrations}/firecrawl/scrape.md (100%) rename docs/integrations/{ => block-integrations}/firecrawl/search.md (100%) rename docs/integrations/{ => block-integrations}/flux_kontext.md (100%) rename docs/integrations/{ => block-integrations}/generic_webhook/triggers.md (100%) rename docs/integrations/{ => block-integrations}/github/checks.md (100%) rename docs/integrations/{ => block-integrations}/github/ci.md (100%) rename docs/integrations/{ => block-integrations}/github/issues.md (100%) rename docs/integrations/{ => block-integrations}/github/pull_requests.md (100%) rename docs/integrations/{ => block-integrations}/github/repo.md (100%) rename docs/integrations/{ => block-integrations}/github/reviews.md (100%) rename docs/integrations/{ => block-integrations}/github/statuses.md (100%) rename docs/integrations/{ => block-integrations}/github/triggers.md (100%) rename docs/integrations/{ => block-integrations}/google/calendar.md (100%) rename docs/integrations/{ => block-integrations}/google/docs.md (100%) rename docs/integrations/{ => block-integrations}/google/gmail.md (100%) rename docs/integrations/{ => block-integrations}/google/sheet.md (100%) rename docs/integrations/{ => block-integrations}/google/sheets.md (100%) rename docs/integrations/{ => block-integrations}/google_maps.md (100%) rename docs/integrations/{ => block-integrations}/http.md (100%) rename docs/integrations/{ => block-integrations}/hubspot/company.md (100%) rename docs/integrations/{ => block-integrations}/hubspot/contact.md (100%) rename docs/integrations/{ => block-integrations}/hubspot/engagement.md (100%) rename docs/integrations/{ => block-integrations}/ideogram.md (100%) rename docs/integrations/{ => block-integrations}/iteration.md (100%) rename docs/integrations/{ => block-integrations}/jina/chunking.md (100%) rename docs/integrations/{ => block-integrations}/jina/embeddings.md (100%) rename docs/integrations/{ => block-integrations}/jina/fact_checker.md (100%) rename docs/integrations/{ => block-integrations}/jina/search.md (100%) rename docs/integrations/{ => block-integrations}/linear/comment.md (100%) rename docs/integrations/{ => block-integrations}/linear/issues.md (100%) rename docs/integrations/{ => block-integrations}/linear/projects.md (100%) rename docs/integrations/{ => block-integrations}/llm.md (100%) rename docs/integrations/{ => block-integrations}/logic.md (100%) rename docs/integrations/{ => block-integrations}/maths.md (100%) rename docs/integrations/{ => block-integrations}/medium.md (100%) rename docs/integrations/{ => block-integrations}/misc.md (100%) rename docs/integrations/{ => block-integrations}/multimedia.md (100%) rename docs/integrations/{ => block-integrations}/notion/create_page.md (100%) rename docs/integrations/{ => block-integrations}/notion/read_database.md (100%) rename docs/integrations/{ => block-integrations}/notion/read_page.md (100%) rename docs/integrations/{ => block-integrations}/notion/read_page_markdown.md (100%) rename docs/integrations/{ => block-integrations}/notion/search.md (100%) rename docs/integrations/{ => block-integrations}/nvidia/deepfake.md (100%) rename docs/integrations/{ => block-integrations}/reddit.md (100%) rename docs/integrations/{ => block-integrations}/replicate/flux_advanced.md (100%) rename docs/integrations/{ => block-integrations}/replicate/replicate_block.md (100%) rename docs/integrations/{ => block-integrations}/replicate_flux_advanced.md (100%) rename docs/integrations/{ => block-integrations}/rss.md (100%) rename docs/integrations/{ => block-integrations}/sampling.md (100%) rename docs/integrations/{ => block-integrations}/search.md (100%) rename docs/integrations/{ => block-integrations}/slant3d/filament.md (100%) rename docs/integrations/{ => block-integrations}/slant3d/order.md (100%) rename docs/integrations/{ => block-integrations}/slant3d/slicing.md (100%) rename docs/integrations/{ => block-integrations}/slant3d/webhook.md (100%) rename docs/integrations/{ => block-integrations}/smartlead/campaign.md (100%) rename docs/integrations/{ => block-integrations}/stagehand/blocks.md (100%) rename docs/integrations/{ => block-integrations}/system/library_operations.md (100%) rename docs/integrations/{ => block-integrations}/system/store_operations.md (100%) rename docs/integrations/{ => block-integrations}/talking_head.md (100%) rename docs/integrations/{ => block-integrations}/text.md (100%) rename docs/integrations/{ => block-integrations}/text_to_speech_block.md (100%) rename docs/integrations/{ => block-integrations}/time_blocks.md (100%) rename docs/integrations/{ => block-integrations}/todoist.md (100%) rename docs/integrations/{ => block-integrations}/todoist/comments.md (100%) rename docs/integrations/{ => block-integrations}/todoist/labels.md (100%) rename docs/integrations/{ => block-integrations}/todoist/projects.md (100%) rename docs/integrations/{ => block-integrations}/todoist/sections.md (100%) rename docs/integrations/{ => block-integrations}/todoist/tasks.md (100%) rename docs/integrations/{ => block-integrations}/twitter/blocks.md (100%) rename docs/integrations/{ => block-integrations}/twitter/bookmark.md (100%) rename docs/integrations/{ => block-integrations}/twitter/follows.md (100%) rename docs/integrations/{ => block-integrations}/twitter/hide.md (100%) rename docs/integrations/{ => block-integrations}/twitter/like.md (100%) rename docs/integrations/{ => block-integrations}/twitter/list_follows.md (100%) rename docs/integrations/{ => block-integrations}/twitter/list_lookup.md (100%) rename docs/integrations/{ => block-integrations}/twitter/list_members.md (100%) rename docs/integrations/{ => block-integrations}/twitter/list_tweets_lookup.md (100%) rename docs/integrations/{ => block-integrations}/twitter/manage.md (100%) rename docs/integrations/{ => block-integrations}/twitter/manage_lists.md (100%) rename docs/integrations/{ => block-integrations}/twitter/mutes.md (100%) rename docs/integrations/{ => block-integrations}/twitter/pinned_lists.md (100%) rename docs/integrations/{ => block-integrations}/twitter/quote.md (100%) rename docs/integrations/{ => block-integrations}/twitter/retweet.md (100%) rename docs/integrations/{ => block-integrations}/twitter/search_spaces.md (100%) rename docs/integrations/{ => block-integrations}/twitter/spaces_lookup.md (100%) rename docs/integrations/{ => block-integrations}/twitter/timeline.md (100%) rename docs/integrations/{ => block-integrations}/twitter/tweet_lookup.md (100%) rename docs/integrations/{ => block-integrations}/twitter/twitter.md (100%) rename docs/integrations/{ => block-integrations}/twitter/user_lookup.md (100%) rename docs/integrations/{ => block-integrations}/wolfram/llm_api.md (100%) rename docs/integrations/{ => block-integrations}/youtube.md (100%) rename docs/integrations/{ => block-integrations}/zerobounce/validate_emails.md (100%) create mode 100644 docs/integrations/guides/llm-providers.md create mode 100644 docs/integrations/guides/voice-providers.md diff --git a/autogpt_platform/backend/scripts/generate_block_docs.py b/autogpt_platform/backend/scripts/generate_block_docs.py index 4fa85e9bf0..bb60eddb5d 100644 --- a/autogpt_platform/backend/scripts/generate_block_docs.py +++ b/autogpt_platform/backend/scripts/generate_block_docs.py @@ -34,7 +34,10 @@ logger = logging.getLogger(__name__) # Default output directory relative to repo root DEFAULT_OUTPUT_DIR = ( - Path(__file__).parent.parent.parent.parent / "docs" / "integrations" + Path(__file__).parent.parent.parent.parent + / "docs" + / "integrations" + / "block-integrations" ) @@ -421,6 +424,14 @@ def generate_block_markdown( lines.append("") lines.append("") + # Optional per-block extras (only include if has content) + extras = manual_content.get("extras", "") + if extras: + lines.append("") + lines.append(extras) + lines.append("") + lines.append("") + lines.append("---") lines.append("") @@ -456,25 +467,52 @@ def get_block_file_mapping(blocks: list[BlockDoc]) -> dict[str, list[BlockDoc]]: return dict(file_mapping) -def generate_overview_table(blocks: list[BlockDoc]) -> str: - """Generate the overview table markdown (blocks.md).""" +def generate_overview_table(blocks: list[BlockDoc], block_dir_prefix: str = "") -> str: + """Generate the overview table markdown (blocks.md). + + Args: + blocks: List of block documentation objects + block_dir_prefix: Prefix for block file links (e.g., "block-integrations/") + """ lines = [] + # GitBook YAML frontmatter + lines.append("---") + lines.append("layout:") + lines.append(" width: default") + lines.append(" title:") + lines.append(" visible: true") + lines.append(" description:") + lines.append(" visible: true") + lines.append(" tableOfContents:") + lines.append(" visible: false") + lines.append(" outline:") + lines.append(" visible: true") + lines.append(" pagination:") + lines.append(" visible: true") + lines.append(" metadata:") + lines.append(" visible: true") + lines.append("---") + lines.append("") + lines.append("# AutoGPT Blocks Overview") lines.append("") lines.append( 'AutoGPT uses a modular approach with various "blocks" to handle different tasks. These blocks are the building blocks of AutoGPT workflows, allowing users to create complex automations by combining simple, specialized components.' ) lines.append("") - lines.append('!!! info "Creating Your Own Blocks"') - lines.append(" Want to create your own custom blocks? Check out our guides:") - lines.append(" ") + lines.append('{% hint style="info" %}') + lines.append("**Creating Your Own Blocks**") + lines.append("") + lines.append("Want to create your own custom blocks? Check out our guides:") + lines.append("") lines.append( - " - [Build your own Blocks](https://docs.agpt.co/platform/new_blocks/) - Step-by-step tutorial with examples" + "* [Build your own Blocks](https://docs.agpt.co/platform/new_blocks/) - Step-by-step tutorial with examples" ) lines.append( - " - [Block SDK Guide](https://docs.agpt.co/platform/block-sdk-guide/) - Advanced SDK patterns with OAuth, webhooks, and provider configuration" + "* [Block SDK Guide](https://docs.agpt.co/platform/block-sdk-guide/) - Advanced SDK patterns with OAuth, webhooks, and provider configuration" ) + lines.append("{% endhint %}") lines.append("") lines.append( "Below is a comprehensive list of all available blocks, categorized by their primary function. Click on any block name to view its detailed documentation." @@ -537,7 +575,8 @@ def generate_overview_table(blocks: list[BlockDoc]) -> str: else "No description" ) short_desc = short_desc.replace("\n", " ").replace("|", "\\|") - lines.append(f"| [{block.name}]({file_path}#{anchor}) | {short_desc} |") + link_path = f"{block_dir_prefix}{file_path}" + lines.append(f"| [{block.name}]({link_path}#{anchor}) | {short_desc} |") lines.append("") continue @@ -563,13 +602,55 @@ def generate_overview_table(blocks: list[BlockDoc]) -> str: ) short_desc = short_desc.replace("\n", " ").replace("|", "\\|") - lines.append(f"| [{block.name}]({file_path}#{anchor}) | {short_desc} |") + link_path = f"{block_dir_prefix}{file_path}" + lines.append(f"| [{block.name}]({link_path}#{anchor}) | {short_desc} |") lines.append("") return "\n".join(lines) +def generate_summary_md( + blocks: list[BlockDoc], root_dir: Path, block_dir_prefix: str = "" +) -> str: + """Generate SUMMARY.md for GitBook navigation. + + Args: + blocks: List of block documentation objects + root_dir: The root docs directory (e.g., docs/integrations/) + block_dir_prefix: Prefix for block file links (e.g., "block-integrations/") + """ + lines = [] + lines.append("# Table of contents") + lines.append("") + lines.append("* [AutoGPT Blocks Overview](README.md)") + lines.append("") + + # Check for guides/ directory at the root level (docs/integrations/guides/) + guides_dir = root_dir / "guides" + if guides_dir.exists(): + lines.append("## Guides") + lines.append("") + for guide_file in sorted(guides_dir.glob("*.md")): + # Use just the file name for title (replace hyphens/underscores with spaces) + title = file_path_to_title(guide_file.stem.replace("-", "_") + ".md") + lines.append(f"* [{title}](guides/{guide_file.name})") + lines.append("") + + lines.append("## Block Integrations") + lines.append("") + + file_mapping = get_block_file_mapping(blocks) + for file_path in sorted(file_mapping.keys()): + title = file_path_to_title(file_path) + link_path = f"{block_dir_prefix}{file_path}" + lines.append(f"* [{title}]({link_path})") + + lines.append("") + + return "\n".join(lines) + + def load_all_blocks_for_docs() -> list[BlockDoc]: """Load all blocks and extract documentation.""" from backend.blocks import load_all_blocks @@ -653,6 +734,16 @@ def write_block_docs( ) ) + # Add file-level additional_content section if present + file_additional = extract_manual_content(existing_content).get( + "additional_content", "" + ) + if file_additional: + content_parts.append("") + content_parts.append(file_additional) + content_parts.append("") + content_parts.append("") + full_content = file_header + "\n" + "\n".join(content_parts) generated_files[str(file_path)] = full_content @@ -661,14 +752,28 @@ def write_block_docs( full_path.write_text(full_content) - # Generate overview file - overview_content = generate_overview_table(blocks) - overview_path = output_dir / "README.md" + # Generate overview file at the parent directory (docs/integrations/) + # with links prefixed to point into block-integrations/ + root_dir = output_dir.parent + block_dir_name = output_dir.name # "block-integrations" + block_dir_prefix = f"{block_dir_name}/" + + overview_content = generate_overview_table(blocks, block_dir_prefix) + overview_path = root_dir / "README.md" generated_files["README.md"] = overview_content overview_path.write_text(overview_content) if verbose: - print(" Writing README.md (overview)") + print(" Writing README.md (overview) to parent directory") + + # Generate SUMMARY.md for GitBook navigation at the parent directory + summary_content = generate_summary_md(blocks, root_dir, block_dir_prefix) + summary_path = root_dir / "SUMMARY.md" + generated_files["SUMMARY.md"] = summary_content + summary_path.write_text(summary_content) + + if verbose: + print(" Writing SUMMARY.md (navigation) to parent directory") return generated_files @@ -748,6 +853,16 @@ def check_docs_in_sync(output_dir: Path, blocks: list[BlockDoc]) -> bool: elif block_match.group(1).strip() != expected_block_content.strip(): mismatched_blocks.append(block.name) + # Add file-level additional_content to expected content (matches write_block_docs) + file_additional = extract_manual_content(existing_content).get( + "additional_content", "" + ) + if file_additional: + content_parts.append("") + content_parts.append(file_additional) + content_parts.append("") + content_parts.append("") + expected_content = file_header + "\n" + "\n".join(content_parts) if existing_content.strip() != expected_content.strip(): @@ -757,11 +872,15 @@ def check_docs_in_sync(output_dir: Path, blocks: list[BlockDoc]) -> bool: out_of_sync_details.append((file_path, mismatched_blocks)) all_match = False - # Check overview - overview_path = output_dir / "README.md" + # Check overview at the parent directory (docs/integrations/) + root_dir = output_dir.parent + block_dir_name = output_dir.name # "block-integrations" + block_dir_prefix = f"{block_dir_name}/" + + overview_path = root_dir / "README.md" if overview_path.exists(): existing_overview = overview_path.read_text() - expected_overview = generate_overview_table(blocks) + expected_overview = generate_overview_table(blocks, block_dir_prefix) if existing_overview.strip() != expected_overview.strip(): print("OUT OF SYNC: README.md (overview)") print(" The blocks overview table needs regeneration") @@ -772,6 +891,21 @@ def check_docs_in_sync(output_dir: Path, blocks: list[BlockDoc]) -> bool: out_of_sync_details.append(("README.md", ["overview table"])) all_match = False + # Check SUMMARY.md at the parent directory + summary_path = root_dir / "SUMMARY.md" + if summary_path.exists(): + existing_summary = summary_path.read_text() + expected_summary = generate_summary_md(blocks, root_dir, block_dir_prefix) + if existing_summary.strip() != expected_summary.strip(): + print("OUT OF SYNC: SUMMARY.md (navigation)") + print(" The GitBook navigation needs regeneration") + out_of_sync_details.append(("SUMMARY.md", ["navigation"])) + all_match = False + else: + print("MISSING: SUMMARY.md (navigation)") + out_of_sync_details.append(("SUMMARY.md", ["navigation"])) + all_match = False + # Check for unfilled manual sections unfilled_patterns = [ "_Add a description of this category of blocks._", diff --git a/docs/integrations/.gitbook/assets/Ollama-Add-Prompts.png b/docs/integrations/.gitbook/assets/Ollama-Add-Prompts.png new file mode 100644 index 0000000000000000000000000000000000000000..deeddc7cc8f13f62484fe145d67396d17b58a828 GIT binary patch literal 118022 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

-5zHU} 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

=&FS+#Rjwz>~3HJk4kR1R&&&|0QXPw< zc~V{kbK*gMP6+dXVkHI0SArWq)Qi0m+x!Kwi?Jvxy#4$m0XbRasYueyoAx8~p7p7L z6FvUpsM*0QH>y}=I!KeC3#KF?BFelp`+C7=&#jA_hhJ>J0g^bNk9CdRtx`JYyGs?g zSo^$Q%fimF*h9KnRXUVGK6bJMA-_AH87|)u^B%aZC9@_!hMV7Y8+k0uWA3_$4920m zJ*TB{8yy2UTU^Btjrm#u&;_(Gz>m_O@W-~8#Qv6qrT_)S5WAIghuii0Ce7_t&o#KS(< z3S)=qCFV&^d)6ycKCQZ&53EpGigw*^S=7Wf{E@sm4z%ocTbQSdCOl|&2Wr;?Rtc!N zwWZb)O{uav)K)dYjqgzp>5>uv(iqmi@RBa&Cl1$&i;%?a5lM+NjAp<`*Q~vs=D^!flUugsid^Ge+@kkyH%T1T(sYQx^k!F7G!S#D zuQ-V73rCd}*Ijd?TX^^;6FH&7~gZlC-(rahkn> zW2u53@vZIRmddH*pgBSoUDV9QHeX?pwE1T1)=7cfq+N5+I+eVPSlZFgfY9fZC7 zUD`r13zNAz0pKH_m_})VddA|i>~Gg{ZN)1882cLJ8#y^+>Wi`ri?W!!s{7*pRi8&F z+i%iXZ|ITOLwGke!ReHLrDVt;O}wq3T>|xj63I7pA7IBFT(YTD)uC_=xu z0Do5Pq<2no`kVTfx41Ue+?Z-OH>K0XD#TzdhkT1vUBuly5BgixJa*mH1@jhV^nRf{ z|3(SJNnOS0dW^=AG~WYYAL`I(s{uZ$eeoSC&A z+Qv1ISIzFXN^l`5cPLL{TMKMMZM}L7Z1JFzYrpsCp(u4|Zf3@PbaWKW&&%rqSDpbu zsH=M%6B|pM?d%Ni=*LzKY3b-xynekGbQBVvSSnFyZy&r=ZY5kGU7pzoY8}G!U%s6M525~Kw6L076s{J@?_wzBO_6_1XFWh;gQ3< z7s^A{J8|e_#^JS9RzPD6;+Ad{hO%gd=#!1w&rC;G(@Cu=^}Vx-YaZnIv4l;!tyWsStWcB=OI{dbDmx{LsPtN!Xf9n1^` zH(WBb9YYHL`QaL``>_TLy5rx6@2yj`(7x6k5q4J5T-wD69bE{O+kUwR^RI^oOFqd zMYwvjwMb(KHlm$KMOuR=Ul)hKN6^##zZMEie!kQif^>!Le+UCD4dBDy*s_Zg{_tm6h8cQw*M#XVgCy)S zY!-ic0KPUu1jdX)IUw5$38l!5Rl$%yY(n1LS5+3Qk-im|$S)Qiu;=DiDEQi<-viFJ^<9DQI~Jn~0$jPLr)LsWi^WxsWvE-HU!Rkx8Gmfx z88J^J5@X*sv8agZjy?Vn2fAxsuvT+OD2S0o9UUsRj{z;ID?;@KK;ha-<>NpxpYl88 znQ|<~x+CrSWXg@vPr!dxvEPss_>^AYvw|1$UGHo~3or5mT&bWNNK_97`IH~}sdK=h zopYP7^^7!S_wZjVn{pOn*~0n@8XGTQw6(NOWn;0kD2&YUf$^oUPO+33N68!m;6{E$ek%-)N(m<;c=*HucEG4=Y ztNN0QncwwA#Zrqx)ztMP!C&w$?m?+OWUPlm_DJ$+Adlkbjkh(Qg)1n*Y!jHP_xaLF zXe8VNv*a84R^_B8a+M!}?)-|TZBLFH;lZQ=RzrD_gPg>M`hr>vOCTPw&va^=EjB+w zbFb=*T$rB2l>&y=9aih&@%~UnT4%`PW`Hi3$me=@9ukpEf(#ufZSpDq#00--xJUNf z$Xs92xnc?=$p3N(T%QX#Tyx=88O*MO82uTZ?a=>Z-`SjW1y1Kp+;|B9XJe&9P$ZYK zHl$w-$nbo#yTu4Ezgucgi#=C6VQ=3C$%Bv#pK;RUS-r$dZO;$)i69%&jsX6 zg__^M@Q`9goZsZL(`C|xbm3avi-FFFOpxrse>eq_m=o)=6hU(;DrgBdVzxeGP~ia@ z7R|%0rUpSX-5-)?Zm)HhRFAv&TZ>9ZH)?7R-P^Xblt3FyzyatH3z}5FdH5YP?Srch z$YrglmWEYPpGDDmpwGD^5Z-nlw?QiLCMeXuyS=ftIv?!#Acgva8#7gV;Zh}`nmX8Q zMmmNO0Akc=-+{Kak@Ujrs4p>>1p+uBE&0OAXiv?$@D=j#BQ`Hbl_jWfwqLwj8a1ss zPnpe?js;a~BX`_^fr%7l7lry=fu?51wPcHSU}x0==m#p05CK+8^Y{-$M!+5s0~mGZ zgkb$X6)mj;QF{U{1*yFamCTJwIZC)*WOMc7tkiP@@_E;=l{+K5?=H`Dd6Da%2 zDctJotS?VbKCoxNf;TgZ!U$njQUfL@!)xN?X2YBDo;gQ=GI{r|VbE(J#b5fzkW=lB z`A@9U#CT7*ZD3sG=6dZj7N-P!4?#BD^6K9$>=U!ymRGrSVu#0gQ^e2&nmT|xU8SMh z;Vc5hyS880s2tjVmjIm@y|f60`(`juwdAm42A+H8L9-lXQ_pp{NnQ2!JIo+d%_rMk z+6L^T)rA6Z(Dy%w7>9jUi*fy5OYLJ`Gu6`%|1BWIvSYvh3jXzio6-4E#8ep=`&9=Z zdU-Y1D}pxld0>bnORnS6S;-3jo|ajfd%s>{t^VD#L<=UkxZ(Oj2M zrGU@2c7UX={QXeX9zR-Bv|jpE9Q-`9yTYpkxV?~j%{aVK{RjWjX?lI54$_b1wfxxkMJRfHiv4RKA0!)}KlD0pBIbFUNJ*5uxT_Lya7YQurLXN(1d!=WN9`NDYuF z3izOA=#B2ofn_LK0-5n`#+R2aT-adL1YKr9&!`5k0n(VAt7$-vUr1bhO%q;c@$zMF z-VrPmPAq%)<;$1NEb*3SR>OQc-rCkN?G#u0aE}=x0tQo&b?X*%m&6+4_mo;Io_D$n z2trB)Z}c+BW>LmkGo?sHQ0DUw_a@4}=A(aSp`6M$Wf_v1w`SR$`aR}^y*BN<8*tZw zqs1@Jf9$CWpHbG$5!AJDi6kMoaZ;=vj}lChyAtH5nK`7s1aY4BDKa1?lAo|cytIim zxo0@;Jlk}Is4$&OD*)j6197sj$08ThwVRsR$Cm~szC5TaUepabCq8Dx;I&bNP2tyF zVQ1W9-n8q8cB<*n7_*U&J_5u+E{*IR3EoASRDK4Zi3t%=>Q(<-^Qp9I=^GEg^J?H6j3a>Xd0!QzsAfYw@866MS?VOP+G2GKiHpO1~Dlu2|dyBC^?h=FY z19gUtfl;nq&dUo)a6@%epH~5HuEHqN%{NO#RAbcNbUr158)Oo zlk+d**R(bbzKV`#o)IM*6B;F_!m@OD*4tvXZr&<1IVwnHDyoOk1I_{XaPBJze_c9=nyl-*ONWJ_SmWr>) zp9F|6S|_RqD)UTtjd~DEGJnU5vyDQ4n#iEADNY`P!1oRW=C6a5&VgI$fFc9MUd<6Q zgE#}h1jTSa$pEPtcc6gED0?KCPmVq|9Kz=$v%vPVj)?wt89y%Q7`$Zp22$?nen>p? zp!UyQ;yS@wC*DH?Woia?z(LRL86Gyi1%)6VLO>Q29Ik@C>BDqoVCRnRDlOuE0+lgn zWXQG=fN#PGEx^Z8zdpPG{om#RlOoXd29hDnCW*Md=>KoX|EFddZw8|C{ceDO?)wGR z6{sMBqUbvDcP~pj3HzjkxUq8ku=ZV3oXpgKX-zZ zbOIE8EU&M-z~S&*pqpfICQaJHOq&cSB(aMTV7w2ovUXxY7PTNQcu&IxpGp#c2oKs7 zS$f@1m!VXMu`o8SS5QoR15Z-q;HHZDe<0HTDG-Sn)>+?3fIGzbp#X+0=RzU=taQ2xFmJk_EI29M zT8O+LnhUyc*xnD;Jv1)!*gG6pTU;=3w5s$NFU5REy<%N3JsX1H(XZ^E0Ez>)JxS7- z&28&U%sraeeE<|tMLnr6Mv|h)7YzCIeqlU zM2U?p*^}Env2T}($(o{{oIUoJ0f+&sAVnOgmo*TIKk)Hrl{c6@NP1)f<4}yZL`L?+_hVZv_juy0;tJLw~q}B%sVO=CyH}6KyM)t zYLhY_IwKCv=oxcqaFwH#6;2M>Y#RJD$`ibB8gl2`prj0@<@uB=ORQJ+G&d>%6w{;n z(k9jt4q|o35MCOWqcB^S_PJ)B2=_xlAxe)Ks>5Sf6HpEu;wf!&0$3^hM$jHb) zzsz2~wKsdLym5-&oj?wT0=4+FH3wbTCEM!x&U>Qw?>DTeA<;!5yFX7>H^-6t@MW*P zWW?^!A%+q~NkMrGqwqx5?rlR^oo<+3T?o$Jw*S=|q#FFGD)ScqqziQ7qMUJprAb|;YV#VgLfOdeY=gzAyRP9dh6O; zP8qgowP0akVsO&N;Li9wqN++wQKTzMWnH0PpuMUsE2D8@S@0lVXv#fG+j=VCMVC@8oe0Km@H{wcq{V|Epq6nVC6cQ@S<}n*@I@U&C1R`P$Y6J zrQR9D_JDBbp+7zBJTuu>4xo;z<=m=H+ow>wFeb>yxyAssMgr?S5I7pvn-r<8kVz?o zN|H9pH3)ybjqfmkfMITJ;)!$n@sj6Y32u9C>o?i2K0X{0A%IvZ9@I@giMpvnZ=^Yp zdd;6#={W-9ShUWC@+uL0g<3EZ+VfNkF zThVD_V~c&J7m)#A^!0f663(J@@4c z!<&AFh^f5(Eq@nS=g-OkQ5b9DNbyuw9-;?6-9ELWW(r_Faq6VtSB~P<)l6%PL<}lt z75bcNQ3LlU43M4~Hw0WXUVWDWnq14v`wVZk_KH|{=F}J>dA)zu8Y-_}=~tf%XqCk> zG^CRz`VEza!!95+H{Is_8%JCYE_Bs(8a{Dt&wv2kOCM)O+<_8^f_SaAMixBVfN|Ad5xMrgdO<~sDQ#?CKaDl?7 zn&^hy2k_z(FGJ^o@(iAYZ2kl~7j%lkAZf$4 z4xgs&FIoT+lfKoE8$q{tc7c0`js)cpaUH8XwVCi_1GD4W6+S*{Ee^4r-!6o~;8B^tfTULX3-Ny2)X(l3Q2Jd@#B*@KqVwUFsF= zfK*EP+SkDt+p1;@ihLefLMB53K{;z62w@4~3Tz595Oo0NTYYwe&`3$Cll2hx%VkxmT+sp9g^PO+f?jr#K3+&9Cj%F2m@gL1>>CYIiyN>Jv^ zzNusd+Mz+NO~Ly9xe}Y`=SM3iZ5uOlhpBWPh=J;^n$WDg1g-o5=H6yT~`x>Mt=M-B=9%i6O>{7rUAA9 z0_)IHb5Ay$qx7o=9k-59OARb)YJF>F?6GUxkngZ%h#aUQj99-B+p99FGux|vNGq}P zV){M(@t}^Bg3;-V(|r=s!ZGfX)6@RwZ|vRg zu%K9{+_+ILEw6(LtJXY9SlDMlayU_fHzGIksMl$Q9f8KV?`xU|p+5dls-&BZ^KKJB z1msp&tLcy#2=p+F)po3>z`9eCU6`!USLG(bi=L#9^)N0)Sbw6q($j9(F&E(R`UK=E z(jCRSd6noQoRbYp_%`j19Yy}2!jG&k{Q^_R_I9bx)1)TtH~R$}D2+z>Po;L~w#%_! z3f^KTP`malWs$4Hd_>Mqs7oxR)^u6W1y#L0q4e?Y#1(k`$XaZI;i4_VL`s%GdG7_L zQ4>k^&fGB2T6X=6-a%bMPg2K-#O9B46Ck_rvJ9&3gYS(r2YT_jwaSkJZt#-dyy{`X zxu-8)Z8)q-NQS$TNgS|VdK3^CgzMevjj4Z3_-m$79wfY%BytRSd^z`S;o_bE5-c(d z2w;T5!z2|*fXytWqP76LO8KCDS}kRHa<;BsvK&T;@lknB=#rHH&|lVKaZ{boU$SH zM|WacF2CMzx?d1(3WV3^#2nsFw7Wr`rYvz1p^>+@Hyc5>*Ki?YCBR9B`QDQ{Z8cM-YAu67&x|aS;Q| zmkk6+!z?sXf;b>35$gU*|0?bQF4yXRoB=+}kbjB8bK<~XDs@;@bH@4Z|CH{>J9}h4 z0SD#y;6ni-2g!22vIe68_WM4-NCb&R0wAxa1!OC5sp4(5s5z(&s-y_y1ucxGt>A_T zRJkef-@11E!E>ATcrBc0sJtpnop=Nf&c1~Z9x`WY9JG$B`qWpNMeg*(nZ zz&%| z6;A>1u|9FUqK%8Eis8EnrT_OZWwe5p7MJN?=<6>}0NnU521)>6pcIY%vE(`Kt)a9`V2+O7 zb?B+#TcK<4x-FWkarh3h72psLfhuSMPUGR!L{kz;%DTavWqaMSy+aFKbphOl2YWcc z@_$1{NxSEI*`aEGi{@z;&ECJ}B>9LMKhjS=qt+!8+O))V+&g6W7RGMufS9*` zi3JON!tyaL#|07!CML7FX=d_4t!Kq>51=0Szx!%6g-?v2P0qhiT7qC&JkdY!)@v1# zx4B{!cMIA?>A2c&z%!w#o72GAl}Eq~-=GmBJZ2420F!i*V;JA%0w+Glzzl0rnZ;MN zsfU5BBVa*IhZ|urGuX?{*-p3&NFw3!R@Z=q9XCs&0l!usCWEpp;MN?#fN&=o;a}m* z@us{W69h7pkk_pMCUoGNEUxngQ};&*?lrOI{#+Cr` zg{I`qi5z2*8+TTk*x7zRNZcVk;XnTK=g(NHi1>G032pRIxs5qHG#_y(DKTIY2VdW^ zz?(WQ$OO)hrr-*3GjR$?v(9H=d=)77{aNOP3hbS|5A{d=eO%Ow;(I@CD_TWLUmI5n z7VFb@9myNtTOBi#b{fdDb&@y;4x0$fsxpgHa(K}}S%!Rxp8MQ4;o{|4-KSxypP!#> z?%2+Vj5|k13(deT#0aHo`-WdsIgo9Ou!_Q!T%_YVJ_V0S!@_^j@NU)6GhB4~ondC+ zJBnH0soh-e23=Bb^N3XMy+xyGquhEe=nVH;0Lv?D4aMxnt&1)eBRL3wpml zCb)d(CIF@UTNl*5ip@pt@T+c6p9(&C7ULze80Ws1B0lk_3rr@w6Zk6kmZE77F z0b)97X=!*9rU(JH0Jseb4n{hG4na_NGTYNx5Ewu)g1-RNLYF~tqb@5 zzRh@XW1QoW-{ja0PPQfS#;ae;`>B7|_M@kr>eGlMr2$;Xq$VVN6*eei`27o(`yXk$^8O=i+kOaS$3#sm`cqjtmP%7T>zRU5uK+`e=`Quu2TS{ z@|fT>iKGbl=Qb~^RWvX;K)364vV(`S#bR_lqPB8i!fOcbC7e&ppTa6-1BLWYFB%tqtni=_X3Q^AA|N6}}$Vu#ePF(LR=X&|HW72CT`U@kHt*MG5>X{mJ zFgt{UZyBxIYMk37-^8!(j~T`K6ZF+(@mdjFpegiew@rohgRo{jb;+<{bBo1l(KYL% z%_V_l6!I{f6!F?_9vf4tXH8Bb>DR+Q(V<&<$W-g`+l{eIl!Ip~(`0+EucrFEfb>Y} zMw^K8j46NZdv5pa>pjuE+0w>exA;DNf(|zyK-}P`1~>=qn1wWkQ)eG?Ko4tkAJ3mT z3!_4QFW{||gN5zKxuIH_lA5XW=bX~6f8X-z+ZktMbjp+f(09V`w6k0v$Sv$o(9q2N z&^LHPJ;ZYPAxSZhZrNS8S=`aq!ydbuwkQV+S7u+Y_K#Y1WlT&ZT;{4}d+^rcVPBs> z%EgYdR2~@#17o$Qh>vtvyd7#2sM8jEC4^tbcLmoNW#8yQajyNb=f@uXdRH(1_!;D9 z7=g1sh#2>Jz@Z}y{D8&svpBKVF+79n3Q#Y6QmSQfDd&QQhf3^wT?=&&*)ThDSLzd7 z+Kv+ShO9rz=?GT=AtWEZ6dj63du^a2|1DPmbY*_=*f%MC{6y^xG&KrP(_xFOw;%A~ zu_E~J?{7&hY>>P0@a!>XRz=``CA6kT{lUqH>=$6g^L$2uBa=<3X$)O;-#QLOtiNQY zGC?1XhEzfOG$=3Pxg@dR!#`!EKPfj=HDVk+Pw?PXZWLGceD;9do5 zjz0GXF8njac|Cw`4G08qZT@H zg@u=znwrKTz>1xXZ5#ry(o$1n2NxGDYL^l_gzDW0^M}9{&vr@|G|?o8nQ+|LrLJ9v=>d&jD#f zi7T$VOSa7vMAi@+Bf9wn|EebVLrd9{-j<-flNG8?67L&z_k%SYQ|aCUxyimwIMVf< zJi^4buhs2YKPIKq_sP29gTM&g*p9t|Hc8h5)0<4d z!hZMaJ(u2$g?gn03)zm(kv>;Bn;#nN$Y5oLcatqR!~qSIEJ zBl7wy+;_0DZcOm_sedu?1qnNQvm|w$o%6Du@&@1*a+hT5DZwqmj2Kw?o=TLIG14dg zun`fC?6@>%UWY4VctoU-Gp*@E$FtPJ0t5bfRPm>8^Z>#8SA;JOgX=o<@28Edv+Bwv z9JY16$zWlc3j6nO?Q-umod3mAsE6NS^{~bhoYR6!^U4`8b2U4G0iYWFP)23~bjAg3 z-u9M3^GcvUx%0aCpTu&sM{N2fXvTrPVFgfC;!!v#cA08U&nxhffrlJj+m&64vF6+Y zJEqT4;@Jq5?%sXPo#woJ!&AtPjq=c2FZjTF2JeiC2khVCIWma7n5aPZhA3j=&gDN3 zNTmdC9PbrB*f86}2RET*&JSg@+0uy%p>M(nDItE_jU5&oeT?zgjQo@l3zdg{y9&uL zOz`+@K~bBz9a!+IfNO(mS1o&N%pxDOP~tXNH!-yO5~*7Vaiik@04z{LcNo<2)z=ee zLN5^9VBCGgi!(V;WrTAXtPQt$5D>=Xf>y)zsAR=C^`IRe^;IGKR|-)D#UU;y6H(B8M`OU#c??OU;3_+C z`V=6XWj*_n2>xh;;wUMB+78(N}z(Af~MWqjRHSP%v8xXv~y@CHKsyVa)V zX0))N;P3zcVs`|MVRJ#dvfbU?3ZT!ECKXGY(gCI0!Mc)RQb01&z%;Y97Ll~OHRl`4rt=;M3ZR3{f%OYdGTq zBDJdktT`?XDkkuKkx0$pPZaluspZ5&LFIceYoga*-$BbV8VE8Qg=w4`!6%t2As^x7 zb86_!4lz95u)oV}TX9XLoq-(RuwsdVHnce;nSk8Z1?p+MI?nTB=i>z}?`b?pEDN@% zphaT{UPC(lq;Ef=(H&U!uD!l_2qfYN#96N@XToJ*v9CXz!|zk5@+&4^wFTcawLeIb zlR){k7-Fb(a$bEor&l$a8}~V7Z?Kh&_Do|fct@uUQRV$|eoQNZF_ zo#hyayiHPGvJ}$`ZU2ACK7#tWt06idqi%ToMdG@bnPE72ZuJDojMB>l`A0tk@CbD# z=u4G<^!WG+SW1E$5>C>JI9m&W*Z1?oAk8ko9)=L#2w&ZiGj^YqR@#_zjg5`MBw!PS!!a(F6z2p71j$mG2R zh53mXVjUG&?C02*$IvE|w_r#L-Gl$^zvw)H0nSa4i64O=R7Q7LKtNvlZ-^n@F))p~ zPb3BZB-Ekl1Db2_rIqv>-KsWN;;M_7lgLec!&i%$7K(y_N}``7@3*?{!VV8odMYyx8c9$=@j?1)|d zR5T!z~Fj1m8#)4@z$fXfBn!jTsXY@cfuedZZ$1e&Btn<;In$gv4devZ0)0J4RU9nN#B(IhWga!{J#4%{`^+v z4XQwXLZi}9T&*2AngS#N146T+y1maGrc?VItyjE$64?mxn(fkr6SHwEr~3|AD?uho zZP3+Ask##!jiuB0InfI*Ao$BEG1o)Z-Kz~ zo}>k|Q|BP%LIbOhp12&3qyTsjjn~)gafab3m?|EvFaU?eV`4}da5k|!ZgV!AEG9GcV7ci_(S zPXwj$VclKuwFRbWV%!Wq07mzrMgFm04xoU~L($Ar_%}gG=tKjPr@R)Zzrf5ikZw5x zjUnWtWI^dj=l0nUM?Lc6sry^n5JuPmr8_H@UXSCROqh^uhi2>=Oi(}K(^Z_Yd3P0@ z0z`glxO;R9LcUaL=5@$^`8pE6C--({Ah>Zw^Bz84g@fS@MhRu$uNP58$A zkOb;CHE5}vP@$oM`64>3q#9ELS&{!(GX7*03%aDzU%2oBgrd2i*B_907p9yk=2-oU zAy9{v5xvRIe?%zH_0?nstbtm@VZxKoQ;cV7P)Gh# zdPVH%r(iH9nCt^7bwUuj+#cd=lL!OR5x!_Z8(0WDaUckGM?gSdeC4wUIaTe2A`awv zs3zTp`=*ew#4b*g^xs{Rpqs&=XZ&5l8(;2h^3P%Ewwet`2+n3SMxr>;APtCJyu9D10rEG`Fq zmSFdsS-e}S$1>1D#?0_xk~o*ekt?%}KP-*^m6-#u65XIrhR}v^f@C$#ez8EI6_X5c zsqMrPQ1+?qPnsI4#W=Y%UvViHuo^6nk7p=zTvT=Gm1VCetDffp0y-obR#LERTG<)A zn<5JA=ueZJPiDBk%F+$eNT51$VXYSHh5Z_;YVMSGBHc}U=Tij9N!s$7nwsioUp&FE z?_N#BJO%yc6j?Ed%^*8R7a&&B6Dp@AdhqKtd>m+#N%bH(E5I1ZdDid-L<0)wECuA; z@GggB)=8fu>Vgyb-l%5pl}K)F)zHamw{28n@m!F?%h#folZTxyl`@R*V%e7u03pCv z!S zF7#bF0r0Mf2tlrTZ8y&w&PlLQKKqd?CS=r{{OuHSa{re11!97H>y>dlkAMft6q0a7 zT!YL?I-4)RH@hUTp8F+v@D4HX*lS|zZoy;V8Mr;6YBc1w1lD0cG^+Xs0=;VIGKKmr zgdj~^>qAokp7gXAqCt-DdldUL;7^Inez-j@Z+N82sA12+%d^!KHZ)z(%&?646O3sw zlvv4cpim2;(J<^rtpk~Bq?J3JM!)Vuq;MLjkUq&*oqyv71MpFx%3QR7{!rrKm33EA z1|L5iW8liCpYV;bt-Ky_z0f{Rdjbgp%!%KBQy#>0Ww+8;YEIL2 zD026uATKV2aTKh)W<(`=6Cn2H$z6d!-+3~`M*YdP4m+=+s_D1%sJiVhX7ToRt_}1Q zYCcpFs26Dikyd$#!BrrRq_{reG51Mk+_q}<_j!NX#?4$8L_4s0?l;#2^4x-|c6#L} zK&Eg4)HTFT`a4a*_YXbtYBBD7ES%W~Tr0d)D>H)D!@o#7sdvJa^!vmrQ}}HgOm+OBke3Pid3TtzQiR=HA|ImtVf5N1|L9Fo0qBl3K#n zVet;Xf)-!u5H)39;U9wlpXsDq6K)G}YwMV`b(AJAYttga(_JEgb~D01aP4efmp5ix z+$K;)v{5N=W(YxH61c^3Rn?m1R&+@Ea+YS^-r_abC7x1nW@`{ydV6iWX8cAC17)ef znz5(>O~chbchqLHSqVJiA!r1B4AX-x1O--dC9>Mk7VP(t3_VD*QrT2OvSBR7RtOW{ z6hUljI8>3U<{`kN8Sg46#r~Kca(Xa~(vgvnN z(=Aov?@>tx-Zu@EG1)=9afMyF0?l9vS-L_cC^A+f_pz8FI4JW*meufl+>?!n1nTRm z#^ZP|C+9)pbHZO>m)_EBy|ntX0j@yMS9~+h?5|*tY;11hP96iO4~|EK@yV&bibpgn zt)q}JwTJXWpg!CV6kbPovnMJL49b#QuY2uRp*;?g)a+Q#GrVlMN)p@>rX)V%LD$C2 zCks=L9ny0M@)r79O;*AIM=a!3RgGO&ik2H68ClG;c)tURo&k${O?XNP{Fx~yLs0vU zF18GK%O3lJa2>Yu?Ep|406*f001^E)-jHB0YIGn~8ks29{`y}Q11@SBF%*5wnJBCu zU{5`tbmaxlzC5`P6far-7x7Z)=sw_onnn|jgZjqBD?ZeWecYU3H7KMVcF)e?l>~^o zFhrN5d$Z<2Ewep#+Z&^_58g#@ZJ82ZvJlA8wu>mQQ+fiqfb}U&^Rv17RfFCFTGKlu zM(`j_#*2VBdz9|a^P3>u1hKyUj``z;8XS7ID$VK{8b3a=<;ohiY=`%nY9FZX{PMXs zN=>{!YQ@K>vEOn|MJ)y}ct1xb%zpu53shbrWJVH)rHm!;twhD!lv+=d3 zBg1v8mEDZo=Qqt2y42wcJTa5cIJ2W)t6@?sYv{%wc(FTQ4kB42eq?V=$0N!HU_DbB zV_Cf6jJl>{Jp#FmPp}pXf%8I(o*-gt@wO-IXPot)8B{r?fCa|#r?!)N z2OCTy6#Mzw-TEj)>_&_{)Pw%Fj`X3~meIc?QJYUlj*ix)56ei>@+@(j%J20ger9Re z?0>V3T^!l@vVjX5ZIE?KI!blA(R!@Zrbzsns;n!eaGCYUEq49Juhowv`;7!_Ke;Ft zSUG!VpvFK!@SD6~^mmDP*xry9637j05e}zGpcmCaX?~eIV6HZ5<~rCe59VN+X#@Ps z;+!It#rwb{1-^ut;QpPgL&DEZayBWKYeM# zx>n;{nh>o(P1Q!QwhiG-hdC5G+#1Y`X3SK^Yr<{hc6OMh?s0_{z0Pz2j7o(93Z#V9ZK9! zm7b$H=CzcORte6@yCr8X00(D|U1Cv;c$fLp5%scF_IiO0s}T@5|4+s$UrM7OhqBE& z5a^myFbPAmpzv4{{(96K-JQ(nwBwrs{Hr0*Y20?eALLlQQJY=^9>gV}eQbeO7GE5n zZt2h3ddW>$lv=GZ{6lFECY_oe$lrI*;Et>_<^mw<%h5;S;>Tpg;!LgEW7V~*15|rGl*o;@x$SCt_FA5ULvPT^tmglXwIw6foh|?m zVD(zS<_n?Td>j5D?lKt}C+?mc)tNf8car*$-*4D}@~cTzb2aw-;0y(@E-yf6dN0u9 zYIiJgy`ax5)-F=hQG=mFdwHNWq98-j`tSp&rB2v=mPwq~XD}z;lE=YjSOwHTU7~ zYC3bpSy5-+;TeWA)zlrgLG1;iZ*YqtN|UPyzk2$SlUgRTpv%tVx)lTL{XK6Qbb^U@ zYIkm`=~KPNVb`IG=&aD#`H9lTQGF4+{gG16FXl6k{uUjm68CFoGz>~g-}`bxP)tk= zll9>!`Wn}%!eJk4SwYsZ(4f}AXAT|t!>>pC=bX!?{mAzpFQj`M4BfZwp?q!oeSpaJ z`;+V&Ek;C|RIa+A3azEjObP{xs-7P*CZf}Vx_X<~{cL{k)n3G4TkgZj(mMotlq4vC z7d5fgueMNO=e135n*iT)25YKn4E$8<_Kh?53(l(*(3lixV2zE9;FcnA&OxD=bRf47 zT(z86+gaBIs;MnWnCXSA4(V*onAG+f3v&Uduo{K{Mg8Jq6^7fWbF698hrBYQJRlp-lVv<o@#>Sr`Td_N$O+N{2B8HNJGV%@-zXm^t`C% zk?k&t^|Wsf{W$iP7YHsC4(=EY1T3X8ba^&rYg<@d0g6#GdScc0BHq4{OtDy`Yd2)* z1Z|>sXU5&L-LKj)r_s-Uz1GSMEZit@|MZ{v*{$kc$H%l}{6qw}(Ojp`8AvKG+kL$1 zT5A{~RuL`r=o-}_4gYSZ?8x(+)r7rTjhvI4sjSL*dq5$IEJ}jVQik=0*xs-SCeHCI zS)=_dv1GxJ2jLT;AZh|r#v_7zeGa>r8uzi+y~P>dOIEh6&j} z4l{pFXlXWoc=fAvtvGl~U&vb6Ok+y6h{MrTJJ&E*sRi>Mahz2aX-2mk7ye1ydjRtj7viKD;xj98Q~oLK!DOy$LdnU{W+ zz2`4wj*Y2kWVTZw9I(YE+ZRn85wj%yVLk_1xiicdo&$BOje-La@C)BGXf3;ZyF|Ai z-%mw7Y^N{FyZ*gRck+QGUSF(gQvIx|C-B>myIS%J1i36&dlFX9!(FP{3Sy8XGs}Gv z?#AD&;>G;8ZmD{`87KEWK^>CQ_T02e zo7Q8)qtb+!AS5_`uI?67pR4hTYGl7o(NjG1uAG-`E8$*p)+}`sJL8lSF zkI#wVC3Qd{_(K{-*+M9LFVUFL-6*W1&AEt-i9+P2_$sYlkmkjBzq>`gzxfu(((Uwx1vEN{0xDeOHy1_C#)m(lfX{4UfE0TJ}RMN?5;5N-?}g5b5?nw00@dtWejps&iG04ko}QTF6E+EEK0`?<+aMMU48%qDWw$37Uv?@ z2i=%7i@Rifr-%znu^q}Mwv-tOmzH<$xBmF?1{vjjSX)8k`IyXVC}&C0>GQk{U(ubB zAH&01ypu@H?3!J1$p|S^i@}uVkbIMz+aCFcwI0MMUnw{WvkpV}z>z~s+)tayJV5uP z?>$FJ3!VKvP(E0wJz_iRFEvv8BTQO|2{x0WDZ+=)DU4W6rGZ&s+bvAX)3nK-*jH|Sn&+%2 zR=uL<$^H+8vmYlxOc$8Rt_a2fP!M$AFBL{LBVS1d@)qYl3Z_evOGIRzFqVWFO&-6J z@gMMA$t6DKUd%#4BXgGuq4p@&Ty&*Okicqrj;E1S^0~Ku*=HB++wqzc8K?Vz##Y4g zh~4&Y1)irTjgtz~A9s|4*YknGRU6QIVtO1~&H-jR7vY{V02#RIf9P5fYM<~SAqZK_ ztBA!!(|^is+G9N{($OHX>Yby@K9eWl%MJdw{Sei0ziw1GK&FGk`cvALbZa(YqO}x* zL(VU>nRDMy10OP+ju`-S67dgLPHmGgQ8OyIoa$2oqD0{x@ z?y^lnuk|!Ump+8DJ(8=$8{PHC+ka0YFNg6Ew6Q+XcGak>64Y{@v|-~F7!%BbnMJ*Xuf--I@dB#&iLK62Adg(q91bnYGrhK9DMN zULDmji#xdbi~QL|*eKuk$E@>LeB4TXi)&GuS!k-5Yvk~>IOj@a9VsRldk`;PXO`8u zc*)r4QP3Y36-*9gVlgWtPYpn7&r|KcnwInIYzi7=8i}TxbuuR6Xib>gJ?C~ z_lWyX03pNhsDu+Qk18wOup^~|?`U~ZImlTFjG;Ih2+koD7Pf5XhkVqD`T+G%B%=HY zoLgtd$785!a<%<=(h?98WM2aS*;o2YauR-u_WOXElA~L8M90Mb{2K+`a)d!7L892H z=#XC9F`3^~RdfuDSx2Oj2Cl|GJ09EZJEun3$P8`vTrB&&xS5$YV(Uc5V>jxL0ozUQ z+|@4e5^d_kopF8Z9)WX&G;AGPK$ftvG)&^CCphlJy9i#(WQolHu?f(9Ru>+lZ*d^E zJ|v0k!mM<`qv!XidiZMq!hf{!KJ8LFkXpQ()fKQOHgz=k6-aS(3_MC*vIo;EqJTSdZe&$ z``aC!Y_AV0n9$ygrjlY{3M9KcgT;>?4Qz4TRQfr$t0_A&hi12{dnw)Ua#mwR{b59j zc4RcUk9$b|M>!%D8sW;N*j#yzengMeG)TD?eLi^zAnhe7NgE>_0%hZbQ4O2r3XU#n zfXuwP8DP2o}kk66ThyAvzrJzO1{CRxT9U^VMG!sBK%#PW9gv#LQZ$zm4OS!OleJ0e z0LCW1g1oZqVSpSRqB&wfyY^elNAA^*wB7Tvl&Kr7bSLwP##FqVZ>#5|e?rgW%kA#9 z*qfr5WKc2lkvuSh!g0RId%HuZ0wkbDJ7QeqM%+}#-9YGIGFLKWpr&#ddLVlc_Kt3n z*;mQgXsCoy5ma)!5x$-7W5}}ROkP3Y-UR2f5)F_Qze%?Rig)^71CzZ-kkXq%4e0nw zGM_E0>A_ogP)h|j*K`?v>W};$;3aF-1b&^LoZN?AOsMI#s4Fr04lfek03<|!of~eM zqJeVfs`Qv#yY64p4jQ(;K+vb5IM3$mOac+ZkCa{IGHdA9`Q;mxQe{pJ&!imn+=m!* zOJ1EBO25Ug1M5ek_)|0u;P&uOu4HX*--f(JTL`H>zf3tR7&fUK-(BFEAVa z*j99F0xBcykyfi}-d@{Qe>S1ffyuzHUm?i;H1Sn8EKvT;UlHmMlt)hS`!O*Ku!Cb| z_YCa*X49K0IzvzUkILsKV;rqF`{iJdB;sKX}v6<1^Gu8bDE%k2y{@Xq;)|Exi za=De0(vsUu>Uy+xkvVOtx7sFSOo5WOlF}jXTpJ3=froLVq204naNaU${J&?L?;usG zbf~B#$-OVZ+Dw?U$T$j=HqGU-6-dMne&sd2H{K$=yPRD#K|i^{#`qg3gqG`;ERneU zIE!5a2?}pVD0(1B)DqoM_8&6|4tDRVkE%t{K#HzrW$wF6^r(lL#wObLdCk!$3hKG&-ksT_pJ&cK)Fpbg|EZrcu+6s0k@%Pa5Oti?N0)S}{_ z3XMywHa&bEJ9BYZcz#YH{mBAP&7G^Ay}?x)ofXed8-9dcFv9|;=nwXr0-6Wx7c;>3usR<%@akQG70ClkQ{OS5=+i+Z|r{qaK0GRGdlax7ySuIJD1(aA|Ja-0rSvbUmexI?0~>Zm7iwM(RAXi2UAgjGNc zp`L6!1ql}Y@hHw1-L*S3^(*hX0?+&&QKe9Zw+i<9SVv*_7^Ybas~*Zby$w1G1I7XN z1IJj$wft}7R`vR^FjugTJ-X`r8QfoJ)BtT>EiC6NC}NA zX@|c1BCFpG+Pp>PwgxCs{PFJZ&-G$%XJEV7tUVPJ5P{P5t4_;MI+<+tGDv1+xOD4C zP1so>DIvhmUd^vVzPsLRB-2_>*EEPX$hsDflSe1~fScC*rvPyV1c=sRppmsIM-}#Ti^rGx&3~~zwOfe8c6v}@`Xp8bm(j8_Mtwl> zm9FN6&;AW{EppGLM4KL_M`|x6kRx44R{^6wAJu(MzDp}3unavQn;c~4F*em4+0Z+n zk(AbZQ|ZH>#3xivJ&h+Ex9&ZrhIO2iejM(7lbXw3S;v?oQYZ6Me6{2G+L=;|PQe?& zsWd$DyYBx_43|L~)WX`sS> zw6jY>`_#*fIa`hK2f5}U#23dLOI8O@uOaru{ah+EdJX?XP_DZ+^NPdK-Cy@lW!849 zgGx7HjZvNVZXdF2qHbJ72%U##oIY%7{UetCg1z87Gh}?KUzF1p)X3psVPTW4OF+DLbBI-WiFAr9w|tyY z{!M={)q68g*^#4D(gr1$Z5}QJi*B`OZwhXUfc0qAWrOVd8jcYOyPV{v2FDxn?zmrulC89G$gR8+}`3#Qd=uUU56ItJsvb&Kr(v` z@q2H?3_$s6ubC+I_<7{|l8eo);vKdk^HYX>I&;VNfLwK)M~4-r-oxz_6&=>-otyC^ zvj4BFtBi}P`?{9_2@&ZKq@<)#Qd(M&5Ri~Ykrol@8Wcr9z#x?FPDyDAk?wA!Yv`C^ znD@-%|J%#A3(W7H*=L{FYwx|*h;FBR;Y5R&yx#XVm5&t@&7*B&4q2X{)WRpg$1{#e zzrqfiZez@e>)kRORgZl|0C#nr!nNX1(BYkBxC%F@%3-EIPKMj(T3 z@y!)2+<@(}4nN-_A&T|o8#z^DQ-AGbcbfv!Tv{`n>$>~_-8539*LP-0wfzQ~Re@;> z>B=ctR5Np_V4YUR!z$R&rMRRp#i4;p5z}m0iGo+U_pag?MMAe1wO~3k4OkZA`nVz_)L^k!U?DRx zUyzrDd}O-D?6b}q*oP-yC%(y;d!`7u)~>`3oC47t+NH-AKG);k#s<*oNQ75^zanp3 zn0y+IaPY&KXuM$Hn(90hOr+t{@~NWs-BC46z`se$>0@yZ#gotldVeD>qCmM2yZ;cD z0hCM;1c66nW*Zb-kOMNN{^u7168zo*1yYe1K2G>X!ERR_jW&}*SM zTa*djF3Lz&oegt*GZ`>T_ zz_+_&5JLzbe8qG9DJ8Ayx9ZHl;2Nx3Z|cYo;)Tk1C^xSr{&5zjfc76KIh5^(kspDqSJ8;xp}`g-9S=s(ex@DBsNfdS_W0>RjZYTT6Bk(l)?!# zH%W4~Y7HVzp?oJoIQJZbo!gAF<>ZrI<}7TwQ78f2OGizhieFDSV5#^H0KH$kV9~bl zA4yd!s9zIl9(gU`E1nEwQ}Eqrw2$IR_C#z9ha1xi@Z|@6IU1FtBJ5@s_;(X$nPNdpAu_(n11U*X+$E?e#6B7Nv%qujtjmVJvlF;3|PCY5ieDN8pJ$`yo65}E)$ z*XvE{M2|BtWSF$o@m){ryW!c@(uS~KQ>is4Q!UeDyZkxMywy#m%!Ug48um{Tm>UcV zn{NM`iMf}KJJq9luX+2OG;dU7Q5gJg4yfSax?Y!)=nSHS#Iv6u!(e1G?f^^8-clbB z{w&DDHz{I$*dvTH5Sel6AO~oIq#$N5%g?9CuQ|n<{IQlc!?7Ce4B20gjofo>N7XCn zXA9P3LZ6OOKr4fW`FyLhT;>l>$ARQ-ur@!Ydmk#~Fk8Z|piggr`5$XQd@)x*nmFj) z39m8x?t|s>J=bYUEr~%@d)(-npcJN?>>kP`-wjjd4l_3_Tnhq@M|6onbw026g0c*W zqF8>x%i8jo^2Ga&WxmevY>hSK>Sa%vh(JLC5bIE{8W7bmHls zyK^HLvmSMRv4K87bb^rV-N1=(`*vAE>vWT{A|m*p=fUJXf9P5JUA$)N?V-f+g|tUZ z@Rf%+W0>+O-_XF5CMGnUewH3Qypq(xaGUto!H4n_o~bcfV#m|i;n>(|3nSDG>T^E- z&{zjV#`q8HHrF{sT0Iuv=op@)SI5Utjy1dddAPs>S0w~!BF;h!Qj3q9g!~6loSb=D(>W#VOmN8BYH0`+$E_rZR($qGFu$F%*<&QQs@4NlV2 zPJ8-N*=MYz!ZF?WF^eODl+)>NTlBPLdPyI)`u^R_q=BU#I4;e(b}-7Re$bOWdYK)U z)8Q@o+aIr8T0x!Ym6&|EFYQ+^yXNAWiiuQy4Ifs$7_+7Px$-Q@5dsY!;~&=`a>$k# z9)H5?BIo^hay)35P?z&W`AT!-^!r53;n8tl0H=me`$BzAo{K0{RdLFZ_h-+P-AYer z2}eT5l1qTzQUplOVH`jewh|^~lyv==d@u`i^r=Yz+Wn(?ExG)!_mjb?!LA~Sy|4uYZMssUlKTbwsKNpK8}UemJ_5g2|32(s@KzJ zwkxC!jsWMkT81b0lyza(20>xqeMrQ|i2+~+K>-)e1~_}2Tq%hhe;x?wqNt9y!+i-B z>b-N$NWK3Q2vU6#s9jgU^knCuozCHX#rOg`?ZCpfw-jtAy z+a}uzj!{GuJX*@x@R)8a+_eJuoGXH|!nbQS-{~RF^Q$k}ri_L_gV-x2Fk=q@fga)P8V3&~09axF3!MZ1D8kbDr!ak< zjaz+km^UrNtF1wCvkYquR%;>>yp`*qJUtoTBz~!|ekwgE)BoABsvOewK1;<$kG1ct z`}p)uKv)u>DoAH`Ipk2O-V48+=g7@NoBw#$S;+E=YM@S;I;GNh zjMQ(}4dvValz3opaAVpJ?Ufl+b$kbVl{ldtJkgc2%xfA=M-KTq#q@)tmRWA3!~d)m zvpm0*?)hm^$F-HhrHI)=6RxXOs)8LWzq7tNSkULJ+PRI`5PL} zZUY-daBdgcPOCowH_huNh^J@-CY5%9y~W;QkveL3km7nhpCIRnJ&R5Fib*nsPt+G# z_~(iV!;qL&EEwQ9aNpv)sHPZl#VWi5-|p(+WPiZinzohav@>anC*{b|R&}FS9_nQF zRCpKES)SZ)+7rg(p5?m!0G0v( zOmKGE>VHxNdIRe+9B;*VRc8$0 zJ5MUBId|>j3lDBqf19A8COY0);fOQcyxeD&TaepMD^b}T?|!+LdG@+wsH}v^fh-#* zU3&M3<++Bbky$t;G)m6we{@Hdsh##TStfkEs$ZtM#ebQt4s}1Z#V*D+HM^cWYLOy4 z?V_M3CnexLsUwIn?emMyZflXm9)FHnhcro}2M@Zpgf{teC4OY*AnP`=6Nm-3tMBIb z6c{v+(UdF$jjtaP(d(vLL>UsIbxFc#+iz?AbOin$A!MPqUwlQSM=<&|HWG1oJ0=EBi|xsq4)3K z+bZYWFGZhO?et=?(rU%A6u{)ibMOj9+>TXs>hCJ(DylNPWuZT!S3fBB#GBv4#Z*rJ zMe_4s=ji))aCG8UR&wf^;f}X`(uX~7CLV_pq#kg% zsv}UszqF?TBtz8*O9TO#7)zs}h5zHA2OLXJyh@a0ydfG?O-S(;LALccwZ%R2V5`~( z9UvI7Lq8F|AYg;U6!||9U`ubvLCQ_P56(}~gP5u?*R=UUXID^vkuK;(+A4~i$QD$V zfik>@7Ic&84n*`-pF}9TZZ1BYenq>YNBYu-kk2-corI*Z!hwjuj;Flq#dvsDl9)`m zb`=*V&(pjLzK1hn-OxY)d1RR3P6g&jF{Aj2dEn>*C+pt+JGSD(6UOAA(GTT}O9L3E zc#8`VOt@mg#U4L4_kS=P0;_5nRD{U`NPzAtTXiX(>`FrzW4#)>LvOS&z{GNo{FdI@$cgMnmJSfVd#V z`3OdRomc$huJFE&)mLdk-cY^Md?)*fkpnq!UxekE^Kh#Xgtv)6WU{eQGz?vNFF28+Maev!_qbZyEns zPI%V+ySgv2Z%rbq_DMsW|NN}~d^rL&`+c-=%JX#lUdmzO^r?T}B)7ucdlH6vRkOTz zlim1ZEf#H8n$tN%d!f?VE6pBc9aF3kX@u|*Mz6y=S#ciFXVQ{2Rc3VK{JB#L^Z3Z- zxnV`^gZ~+ZTy~}Yp zz`nYmfu$|^^RimJ8iRe?oZ=)q^!^KFSP0<e$?@@frCmE`dbI4zUXK@Z}b z>qAP}ruWL@Q2p{3jaf!dzu_h;{!IM4n)JhS-LH30yC?$8QdEVN=-*2bTzPNkJ-3~x zh8pjtn<6EYaSSQU_a)U`2n-6UtE7~(e9FG;Upl{!O?j(uIAO3# zv!rqn5A^=G$$n?Dd^}F7JAOFN#qBu?p|T(!DRe%jyd;Xs`YBOn9)^KIPp0TQULjx{ zo|uEBPX5Z%%bDS?o=ME3wbcKlc&)9KR9n9O^2fFLWP6-Dzgm{_{KrY#Y1c;7y{opdCB3J?)_h$V{^Wx%X`*%NPh_3R6{^E`5 zKi>9&9lY<#JT*835#S|A2BE&LHJxd4v7kPqA6ADg+p15Jy0bOW0hOG-M~3f}SvH_g zS{8l@RM46MZOfehqEUV&%)RL>i=3f*s(qVP`mg3^>mVBSgr(X=9XIF(>@_GQ1Hy9~J?*AWY6bS@+ywOv1or=RTw+8%Rua`?_ z*@v0>l4ZpCTgJ#u@0HX-XD9uU*%W}%7>pU}o+Pa@$@}Y~V$pT^7z3mn!_`t_kxlxT zoLG^qOY767@#0wtZm2I_R1dar+Y@EhHvX8oO9stA z{^Mk`gw;#a97woV{4%?29aBi~?HSKYQBrx_v<&Q`tbpM#K#`G=NM_R~teWbID6$1r z1t;-P$S4d@*>1&s%%M0PB9yAFu07*XS-D|V_W-Y1_oM{bL`9v%do`%yIPlR>o>w-P zr90!&pqZz?3C!~T75Rf=&{g^3{@AIM&rvRF)6j*tP=?HNNXLblrH>;D<3MKN-f8(|}*7D@3jcsp(Er#uNDnjMS3Be$o=n-5)_HSXD zxUn^cBIM)=HbSo#noCXUj~JVp&c_1lX3>E3+b`X^5GhB_X+}jCRJe(0{*tx6tk%3K z>12Dd9mw->f-;`gynIP9`Lm}z%I^_nE9V(bgvSxc8S%CGzzb2TrlD@}%$W3>2NyF! zIAl?BO7e#niQ>=tJj&OAOGP_ zRi-SWn%@pKaEkpkV1g)o*>teggb+Y&ug&I^)byDhl1v8@L@+C?~ED zi2yyPXy}`JERQtU2n|@}aX;|hV|olSWJO>2vNDv3EHfr}#KL#7Cu`>Gp=#%WZh7_r z8?J$sP70ICJRL%K`Q-(}?sBJI<88}fZOn9F&Gd_=Zc?iJ7}8P9DB%X`6X7#sOh~jq zJ4Ya_;#h3ua3d$>s=>N_Y@AtcKS#;lWjS|=vB_1Bpw!>QDz&w>Z;tTV4x?(wRhQHg z!%J6qAdlBQNvr#`)R;X&)5`qvB?kBgw87u3EX2|N2v3Q{5jGOIiZ3TAnJ*5rB{5pG zQGrV2oJ6`eNGF=yFUR-=-b2wjHJPQYRX9!~_&5{0(}?24TafOA*y#(m@qm2;@w>17 zXcJtXGWWVRAw#u)S|BSmW|`5QnlBvZF=ux>(Jp*`!)6zqyt7;ZYotIkZ#EwzTu?-% zGlqANiz$!HXd#Y+AG`0TN_uR6LycAMMibK2aIttRnXfFOZDYgCl$7POC+7T5J>Qg( zEGZ-w&TV({Sv&YLY%xiB4d={+P2TsCK?w0F1 z`&$QTi(g-?)R$giA$q-&AUDA&=8#M_G7Ylhh8V}Px^uzZJ@X(!Kk*)9JOjP#I;xA9 zhTl#2MSU-$q+i?4wA^~}&&%i48*h6XFD>cof}Cij>ah`tRd%BY)-Pfl`^3&{)0x5*v6 zm;SLL@TX2%yqqMFwMUjl(0nAC``5$smC4wEgp&mEw}t2GP)~er3ndPifsaH+e?yuT zGK7IT)i|CK-3TYFmj%94wn|y`K{Ng=PL=C}kS5+u#`u@<*ZIhWsP2%Nv~m7jVzvR9 zdAIS4@2y*iWAWJ9f+nG1^chz_YN@LhUPyXo5*N_d#1b4AJP`@~sq*lTYg&sFM9Q3= z->9yATriIMAtofW{+^VHujh%P>>cE=E*`xxrkYBEBl{MpN9<3$_g5=XV$q^7b)$J) zbauK|nxvE1+~fY-Cslik6N5oIbwB%)`+n$lrYTZv$-m0IIQu!T zM`#5SvAd^yfsDi@XK%y6?ij%)-)fr-uSsOS_v+cup}KmsyqR0!)2HW_Au(yTm@JvUAbEt-78 z2vR3~F8)-?ZGBrSuG!R{-Mwsh@zk)|_k~sn13mqL&70p%p5v?JlbzCVI<1<&7D1W}@U!B#APrVYPGwtABg;`aZ6DU+JCWS8sRQ>*7??rbi)8rvVEYI>W=m7h84=hL~>QW>YxFSPQekVr`r zv-Ejk2@ctD<3XIK?(TCn2)f3Bxqw}7IhKrvOcFww$R_z?hezr3x@Zfk=V7)!AopW3 z|29r@FMAXOEar;!4eT&;GMs&%xA(R?aT%V9OdpbtsAyO_SDs^sXzra-c>-%xv6DHUopD5}eBby+DEzRqO)C6;n#UbA zN8AYtv3Jf^PbcWbXm`jzd|Q5j4o z;n_0{J~Qw!!pd>Nlww~>0MgZ(Gx<4X7O-+`zAWXrTaTLC=0cmm1sg8tJCJa<q*cD|I%RYbmos% zB!{_>3Wk?zzuO7#K7~}8*>Bzy?k+;lq@;qG#CwEb^N4jN}SnrrN4gzhWPm#13#Fhy`KgPZ zK;z4QsE8#KV0?nb#An$aWxoRn$DV=CnJzT8v&Qnv=q+(=*y(r7aAW z;m2|hUX6HlZ21$3*jr?~+-Vy@?fVjWVwzK>0xp6DUG@=wF$e}`4-4cudN&K*?QcX( zTIi8tjAry4{!K8uoAGwHZaQE|VC$kv?sTKCZiduT@TH?lRgL5L{MIUI;ohlVplV3M zm*DeB8+s!rMuNm`?KB;C-Xc;EZ7 z>_qlO1|{W&`Jag%R0EK%Gc%Odw80SEm4`dLtzGG!Pq{fW$x;&gGz;a8-@o)-x^Bck zowRgrq4s`*GsNa}35W3|bVg|lfS>HOAwiqma< zmJ?I_jvWTYrhli!9M=kGh07+Ulo)h=Z&8CuG!%%4>Ep6*8lJPryVRZz97J!*#ck8I zX{l^UY=1vc`krxNY`ko@BD~?+9@ZC+2z@`xbE9uPyl1XQ#!iW;@t3r9OFzRNB9_h<9V`;qv z?EU3~ybZXXKljeOEUK7>enpNQbbkb+ zr92U*B>u4*`bH}aNL~}O!={K;GY*qc&zSBXXAjpXD~nc5YTZx-*J}d3H$z_dU7S^{ zEB&P(S_>KunR&~M9wAAVjka%S@V8E>3mkXbUHG06+M2bKN^%DwtJIXcV4I#o6fh<` z+5D}ld+LtkcwthxM^r{-h)1D8l|omEmudEmc&!iel!V{RbYlxmmAeyVF@f`S<}|;l zfB&u{GEPE%rty>-dxD|BF`AGFE+cdgWD}dBOtzJBWf7>jf3tjIlLGQC#c>|F6W+#i zjhqa7lui-p7p(myDR5_zc*50&uO?>s1BZ%)CnBCG=n?rqaB~gepOHc{G;SrAk|)+^ zAnX>!nYunj7N8Rr9_Z>K*Cywf@`W&Puu5=Fz`#*;T2e3Ci#+$UeC0tVq8pQ%5Ud$7 z`6#!WA3b_YMo4JE+;3kQ+r8b?baBv)?faQ>4sDvRam|?i>9$s}g_ZGK?MvF4^V#BW zYC`SST@vs{wsD6Sy}%I)58?gap!WcVW4uepjHT72fLi-4+I8>%@-_7i4tOj(?X&c- zPn%ruiK>^2Ef4xZL3)MV;IdD%N%af&q@<*|jG5#XQWYSP@Cr17A2RuQFsb7AO?ub& z#R-^g8Wb8Wj!RT;N!)@OL5^%t@6t^ls@TI)bJ%L#xjtQY+7iEQ+M`i#BoG>5+x||G z2+kC7`Oh}dY&u$ig^L2}{ygmc&%mDu)u3zK;#9QQN5Zf2+&E-|*Io8jy5s5qD(+>q zb3ATwn(XKGu?f&IU=>lrT}v8H;!nj?;l6-a2kqVXSJ-?Vv{bwXHQ8Tb8IN)~q1dZY zZipsKBOqQWw>SheX6Xv>*O7U}ka1zo|3_H@*Te!SJVz;C~(u2XG_) zH}0@6*hf4S2xlN0`un+K4IEcq``3d=$Vs*9`Aw>7s^{>I?|}pd*k!-h&k^yM#SeoM^;O1cWED5 ziMh!8#sY!ie}K&g`3JBSpu#PPQ_3!uguTfDi+lug)_hlKkR0oc5E0AV;$VkHh7pSN zew2I9JxmvG$;0Bokcr}oIWCt81&mPzFL2RE{eLlUMLz(CaHV4aDg#_Un@lLJWabfUfyxxAiv5KWiw=we z!tJF~*1$eqRcRhO3|C>;k8pkDS6-%j;n#r4A<~9fHjKEZ{I|x5f?fe3_G*-agGQ|fMU)rs!_zJsQ>aHzS zV80~Ru>8Q|#uPNhu`m_bJLqwU0DVn{dKC8Q*~9-Fl=5;L0J49t>Ab{Rjs^Ce>=X2j zNaf0{|17D49XWBlYgiTbBO+n{*)?I$7Wj2U*A1C}UoVvg{F7r^mgB$g|0Du9l|lET ztpEOcm(xen&aL8}p1|z%DE1_(HH4wdWYT};l zn&w$~241tu%Ex53e`6sA|5Q0JRQYe9MJfvzD4G4Z?os065d?l7C~GMdD_FkyKg$w! AdH?_b literal 0 HcmV?d00001 diff --git a/docs/integrations/.gitbook/assets/Ollama-Output.png b/docs/integrations/.gitbook/assets/Ollama-Output.png new file mode 100644 index 0000000000000000000000000000000000000000..06cb9f7a8a23600bc33f73d7997df5a23c8a56e0 GIT binary patch literal 29918 zcmdSBWmJ?=-!}>jl0yjsLx+NNmoz9LY0)VoA>G|A4N9j-C@CQw4&B||-Ce`E2Jic< zXT9ruIBUJ{hjZi$;x(In?fCCsZ9`R+WpS{`u#k|DaOCA)zC}Vp0U;qFzr{cUeqy~e zn}~!&gCze_^1Tc4ZW?;@r&kU4-x%Lx20kJ>mQcvVI3vOyeVn1{C)Oj5@{tw|maQte zN~=K!L4C%If=BQ)PgW1M|JoQcc7(yeets$~ZK|uhJg%jG^8Whtxb8f?W@*;Ge|KK^ zX75+y#;pAduR4|c#=D)<-;O)uLgmuZ(zwEcf}4BI%|0#X7Z-JnjbhgB?(VuODp6H) zb92Q71wD>VPHuHhds-7iLrdd@nk&;YGrHQ^DFxaS+uOl2^_EooHfDX|_g5+tb8{;) zGBU$x&+erpFffp5Is5S?_-W{)pO`6)$jK^ujp0Dh(<>F$x{1 zE4uhK_u$CLP9>zPj8*?Xzfq5XluT>G&Hd&Ffa}8tXDY0Wm4e-IoM_Hjj zei3F`#iyvMC#n7nqEUjj?HG*$0a|_dXBr>H&Heo|qdhv%+F0l>dJr^9%D5O5*qHtZ zca1?Z$ezaGzN9M)CZ&;&n~xhl@q$xO@M|2pBCN*`gT|cKx71IakaR*)SR!jC8u^EW z8Y$wjd|V4JM;{@SP829g87&Jvx9TIP4=m^o!bDmUu735_TG>$o_{wylP~ASJtzsRFb)0wZ@zo&iVDxkvF75^ zt8?)oiDI6)=4NI}l>-ve(s2a^+6{m41-RP^$NUe;io!;xTu+GCw=HKqUdp#mV%eq8&@XQcyGJndOT^4hz}r>c&)AagK|wPPyBl zLe)vlfH!7mf8K8E@K0`&cdI*>KR1H0F{VmE7aJ~&tH;M3pqY)0f5*m)Un`**_a!GJ zg9~*uNut2-hjXQ`m_ySs5%UhrP$!wM6jVYO|NQKVjaXpKsa2EY4~!7y-|R=@A;Of$z8J!Pjgk&vgn&c9;TzXQ z_5Xg61Q_)t+Do*L?+Jwn9vc5Y*^I=KSmg8d4bqGCcszuf>~Byzv=&eY{N0Ls%pU*Zh8}gY-IQn?NL!Xb%lSv% zhk;*qA{iYuG&B^K_$jmP9#GpvBma+Gk?Ue$z{$gj72_KpuhzlUZaz`$n3k3{@8sxM zv%hcE@w2g0Aug#?3HOGal=RT)kp8OsTSc*U6}t(rpVKtgrCzd4ALdh%X(hPF^RBd> z{N|)Z)E@KD%GRcfd9Z-{dHpCf(@e18kYUIN8T8bX``?t(pi1}%8BtQEJ^Cx*L?gYN z46K-?V8)u<+}!t2gZRi1xVv66d7uqS(7u?r?0bOJfOd= zH6uLRyckVRMRgR9NtmW9nod!;x9ddz=skJ90!$Q?*nv+LxWdue0<%h;1&qsydjx?ON3tLT} zUc5s4?z7c|Sd#`kvWPc-)R_wI)hz90EBYCh_C2yvw>Z1p{p*bf6iL>){w&>nqhX5< zl#M?EdKj(s2p`~8j|aYjOZQ{t{rM^jjPgR{1I%B@P;y8f1j5u{-Lxxp#3s^nDdufO!pZK`kjJp7H24_MLV^uX#U zcFpROWt#TfVD;bh5unF3|LL*uLywmSQo+2u{O;VP9-p?dP-d(R=4zMT2z3f5;=gzd z!5A8B_*afXz)wp+NpZ;QIIP-lHZUbEJ>Shs(@u&f-#wa4ioQcThQ#=Kos$uT30cHP|3hcYZRjZ-wzI!?V)FR(0`J;=COwfY6dU-EROe>RzYncS1H(?u ze3Tvm0qTIYGmCnWlVe}@0D}Q+Q3C3|i72dopg0|i_6PLwBw1nO?(VdctJ8~0DPeMV^)2r`N+1s*#i{bDWTK4 z`mje!U1?OmfNj{*yP^d{h+e=h*Any{`}+EDlT42BG(!|$iqSFt?o?!DyvGHDU3#+I zCzF%4svNfLs;uT}Q`daN%75^D?$S4Py&uIVa; z4-$>+(;WT;lF3OUEMMmanEG0(6vIg0DHur;C;Zx2>ODrQnJDiA|pSySubUEm`}TT^K<`&tVu# zS3gxeyYF^Q#O-CNTz|}b3}d@3H!rSX;7|D>8=1*tNb*Re?_LUJ9L6hIw-PB_OQ<3; zL}}m4WtErabnH55Rkt@=VK&lhHpJ3!W$n46ovwjz{P^ATqIj8>;3(u}WGma=5f$;h z6KP1-1LTtewzCv(cO41DreE{G;h^EdiABA*msai|E}h@cVSl#9s_n~@{cxWC!)X+Z z$LlS4;tqMKc3-@MzQ6^Vt|DeW7ZlZt!Y@}Ef@n^wpk0rhhhCA?j3^8DDJ5|~ljUYj ztvhHGCJ`q?gRMg@^zXLS8)#PD{yrjcF;2GMP34gcqY*e0+0JDtsUN-(J{^>92tpSR zx1Y0V!4dNqKHe=KaC`FP>UtytzDh$xeSg_8_ePd-ZKVS*IWm~qYx0Ixca~(>tMwUZ z$m4{|Us&*QhwDb#1~Hq~YI*34G_FwV#;1!p=ZjCse}--s6F4!j377AWdpPR!ZX6u1MMo=a26*PV&Kc=}g?C1Ss8EGKaq;=G-XRYEO~P5V;B|v0C$q3d_-FjxDgV0p6v;NHE|AF&OCviwnz3uWz%?p*MR~&NCL}5zhWL{L2KnZNoi$ zyzw#$-nCcRggEHmlOOT3%p?2T<@`nC#-r2K zt~k%*Je7=n_L|kl7x0^doBJ$xs>7rE>xrgwuf&kfQqa|uQIu%C)B2qALfG z;2Ms4>9F#9_|44|PW98IOx?quCJk1Vk}ex;w#voE_}d7!&2BRTYisS{^a}^pmc*K&1@&Ls z5hVdjA81VrD#p;qWM6;W7o7(^5>E@|*P1X#@}pZKFh?t(23N;xRvT%~{Wyf2G10F0 ztB@=(Bs16oDf0I%iM}ZP;{uvSzhE^Y%tf}@Urm+%=uF+x;= z*TlHru5V8UE(V69k=>dYGd#-hf^*{VDR%h8&nJz<@CwiFf#Rn96->%iv;2jn{L15Y-08~Kdsfa zcY4-iyg-71%_F+-EePTHkSz7yib;FDl9jZPgmqe|TQ%@T4H^uvC)!E|t~AuMgfi7{ zVQziBzhwFPAHST{uofjMRm6w#`M33XGn}Nk@7K($RLEL=;KGDRgm~ZIEL<$5I}FQv zwKpm4Lobn9J|o!#WOD6fd_##6yUzD+FH`OAJ`cN`o@iX}dd0;pvGSK&@i>YP2E7{m z0L;L)Bw_|ML%|*alztdzyhZ~Iq%^1)WXy?4>Uyqi19lyK>F)bm`_(JBCExkEeS}f- z`UYhWlQ}=0P``V%a^DQXb2#nhYCQtJSzX?k>l0Ym9r*}FDrymEJi@u}<;99;FD8!4 z$zUmmCm96m^W@*s@~lUGKvhiT;r6LZ&~xiLsPQ3ZILnF@-z{yi^NJKb*HFE4P3|wbr*fH%xfcisK z%ry!JC$YTGwq}z1=i|qu%7@^VF$Ag`^F^}!rp%_OrUsNX8|btTf3#kzYRTlm*;7hE?et0wf^ zuf{O&eWkQQxKu*u03=OAi0E?5bM9yg;VYtl6cr1AhBU5*@1H?3o6E&2IfVj_nGj$d zALW}50z$tHd4;B>J)`4(L)PTw{*KyEgu4t5HUXGGAbSRaZuuUCG`G`%dg9qGxMMyj zQ(OmqD&vbwfMCfef7HwY{&E7yL{DQ+XGT8UOWY1>g5A)aL?YusYZ5N6Q#q zm8-VE=(c)tWmQ$3NxJJ$iPLLow0QO++mj6rKoZb%+d3PC8uX`L9d#lVwv+bfj)FBL z&oA&oz^z4nb`OnCD&jwXh1MOIg%4R&B#PI_qXj8yw3o3IYQCs5re%Ah0RJ*&cS2Gd zl>^Ct%8jJj;CV*}GGyA;`^V$zGe^ZhUCdHrG03D}J0;3s@f;8I!$n3S8Q@CVY0yk{ z;G0+f9jZ{oklG>UEzRSBas)^`df4Wiz{D6aJ`5;O_$40-KNuL~$*#PAX#71sV#=|l zp)^8H|DB;H0f^>c0IB-_aC5-=nfLaNPZ9Ty+1c5#M~DS^c^xwV|DPBeTYDia+^}Y0 z((kffxN&~&G%-HDzCB)9d%eVOof0G*n>Q-tEwZ+_n7K11`Rb?5SCfCE`!bUVvUu0o zDYsiF5aF=8sy?S$kYLvNM?NmyPD$y11^gD?-c1OBffy2`TWu4XBI@3_@>H+9rKP2x zbt_zJn0k6?iQwXNgkv?mtzV;csW+)yhn0J9=(kvlS(k)88nl_(kd+&s>Gj={e|zRF zG*c%4P&KZv$BG*~m6#tC#V&sWXzKoiK1L_c2Gy}eN zn_*Kx9|F7S{u5hh%mGmnB5@NzED~XCU~1ZFI1=sqTu9E}gtZTt*#9A2q9Nw~4e|fk zdCphn9-KHJBTJ6ka3j2d5`UJK_SbhOV`O@7b8gX$rw$Jesw}EE|NQy5{9TK)?SW?Z z@a*hv3$5ps;!lS}Y6ZL{0xMJ2gC7DFm!amYw;!+g1g?hh8L7r`7XW~}|Mi`+t%~@a z0|m#t5>t3$xcSw(SVrgyLq=0!`X`9@aA{#W5sNs>%EvPrb_O*o;`l(zPfzsZwJ#e2 z4yvm-S*@^Hu4m`%*#Zd*YN)&}4fUxHYBs3E<3!0y?if9L-!e9O?7bo_S+eljd!1Y` zq#CKDRFw3rVQ^Tgh(WGqOO;pw`wC;iTX0MP&Fj28qbAC8*XVa(FFLiy;-48S;e!qB zh3k0zl-HIU{!Pc-EO+*>7O(Ah8Fm9AC%w;Ms=GTH0C&2%-4Jh*6qKd*tXL52zAe)D z4Wo2f^d+xe^48T&6FS-w0*ID?P5DN;do7;$?O@%KG=eV(xL9vB0yN5giKsS9QsiRR zUf@v0E5jRpl}f_N6d#n@LMOmVat+|Z^so&T@9TF26XcD|@6|?U$gS&#ENj-{Yiw$o z*5bA8K0WE^3;O-(hGXYall!bd|Bg9fLGTCGE}<rc!UqY6N%Mrn;%Jb7P1%78O;^ zPxk>d?+ta4l}w386Goq|^Bi9ZIW~R&1q9vRMg%xo?QD;5?yd&B`|k!4#EvCiz|QHu z$WI?|k?B3VB>~7d)`uJjO$re|wJe`GS~o3-*JI%xeqm4Tb!ofcG-CK8uPebfg~ z>wR)0^YG>O*lv0VzTqjzD+rL0jGObSroH zZ86H-p{Gp%yA?|mw1QohH4eOhBT&G`K)?qk?&hkynFaXxL{f?8o_S+>8h~ubM`@2K z@~FOsMT{xkIORvF3GCGZC+khc?YcnIk?09Fd$j>bzytg15w7s=BF&_2r%&K=7)v4R#ucY?OoRyzzwMyE(B0`sL#a&VAAojj+-h7e>r6Eg(!QoLHRa;{A`T3orxyXQ z?XZDuj4@|kmiR&ai5=8WW}NJd(s_0$&1E^1+?1-uq zZ?1yQc6L#S0w%$!1V{vVx}A^GH-ds5S6$Mwtx{9;LLuVYW~eVnlgED&K{%J7C`k^# z+gN@Kr?>q?zYJUdMoVqOL)eHObusU54eIl$DQ8(|)^p!m<8!dz=&$S`*NOOXi=b1E z;{6wzSoRg4O;PYC87dAMZ}xGP*Hm~tX(5a5S;a?tgKE*vipdAI?@^dnkzGePCCgy> zFSsT6nGTO6(C`n(&6i(iLw?P@={xw{K?N6T^+g*>Y~)MD57-*X+jyOkj%Ju7{Qev7 zt;mdVie>#8{ICV@!Y>Ui`JPvBTuuYjd*Xp3xX+-NxG0aRg&hFbwr*GxI|@~)KJeHO zw(5#~CL~Gz7;k53uzJD$pkY7JDAM-+MErimxHmCI!COe66(oj{>Uy%?_8PRLlETj> zuq#FN`*e`jcsz5)zL=pfET>3AbM2%9zP>0?f0}uc&th03v3`>UbafJ8qPMp*L8Zt0HFLX+%bKaqTuUwHO&bebd$UTT%`tJ}oR$s6fAOJZ=bXNuK0@J|D%`Q8{(T-<~Q5w^`X4scR0LimPG zA&|*W3A**`tfwkT24!%nY_n;idnWQ2UuY-bhS3zP_=0J`mzPDzi~cz#QIa7eEIAwZ zo7qv)xt8AT; z9?bW32yK?k2_~`>Kv++fcB+;AC`(e#5|A0hC|f}gT0OVtywxJoG?qeml7XeEU1F$P zDpWNC_1G+y^RgHg-YmWCRMJ?FW`dJrPFYsX>3Dj2l8&KWP{bYsOryHI|3%LP;qCP7 zm*-RR_p@PwtOXwz@~$`ngbtKUQ;7R-C;9ZRSw*&=wo-z_n@>C? z%9cH%h-E8v{^~+$G|fW6Pg~f&ahPLR1Fx(jI6IPmgzKLvBPWGnO0>6vOWs$SU>dQF z=XL0h6GW)^B!bv4=!pVpwaG^c>ZmhPHn5v2zc0H*vpmU5UtX<&SefKg%@)OET8;p7 zQGQ-De4N>=M-n~0s_hYcs_`5FGZ2&17zLKCO3V;6;(OQrL@_gCWfYB8d4C*h58xQ> zGcz=OE@a=LC{|eXTOb@3B&4jVITPXK|i{*06#MOYqa53m8i>TY1|_y(p#V3*H&ALkh74}6hOc*d!fZFoHK=jH3x;U)Baklhi7z<#}f-pbT zU}+57MwOm-w7stSU%;yh8DpTJeTz%37vaPk?iHsWdOiSDM;;i^4))(F<`i$XQn=2< z4oI!%fT5jjx|?Zo^%b>Uep&KMCLiA6eH-k)8Y64Ra|c2HRG56UpjmG0`i%#MqJ$*M z#DF}^IgBl98W68X7len=6dTDPRiAtvU!CY|V2_tb=rhP|$_2=r|qG`jAZ#EuSCkW;1||A?i14L2B8TcB2#F=42t7E5*&+ zKYzyY!c@~upTNpi({EE$xoCTyWXZP-hUwgWbEa0HfVG6t;D0PBQUc<1~USg3|TQhwgU(D<#afuQZJcLi#XvT zaU)m{2L|B|*d_s4rgBA`~#eW7TlEE-nMK)0}B0#3$c>mo36-(+)i5r9CBmAmxw zhX!*~)8h#DWcu)wpc8B3cgn|17QHTsJQ4%@)Lih)%(0k(qWGv*e#1fXZT7K2h!XIepIQ6naKl*oViwytxT6a zGHf}~WgCA!hdhr5z}6}z-N1#Px6LO!f=-v@1Fl|^XLIk+a@N5wdwyzMV_UvBh2NCl z9%4J1gEwR)56|XZJ8QC%3%C_mDNE(G9`asg@xsaa`pVG$$z$&wRX4M z)?+vDWi4k?c-0-4EWjgD*Va}ifco?rwSyoAHF21lWT!TuWYfP>9h6wlnIGW@!(yT+ z6zD^K3G$i}C}OMxC5=?gyO|dsePp}C^S*4+A+4AFAfLl4ojBgCrGs(P7DkdnxX?T~ z?QQQ*O-9L6EfDKNeykKV%ll}Zec@XamXZ(Vn_GHLi%1kNjJ)?`$2Epgj~MJe9vh}{ zhz6#wo#4eAVEqZgxrnCeaepiD)zU%l6|d#!ljX8%TEYY!yU=@Us6pYffRaX57M%hM z&QAgn1rry(d)sYfNA|*!p%+T@R9054vn0HjGHA^`ot9#fD#;P1By`vhpcXic1q{ZE zs)q20DJr1>;FBN<-26Fw>BkWG$DA)GYsF^F3v-E_!p1v<|_-!Zkz{;wCGFc~Rd+FmAYkcI7gXievxlBYd zOak#uZ$DAth$}-@ObVByavA&X1<#N?zKog8M>$?~$eU2~ju^b63LASe&v-{Q@`J^^ z$w2oD_$#3a-~y!d3B=0qNZk1I6-hkqL%PDT6mku#jmUvyeg|CntPyX7)&S^yPakx? zpiT+9-PL#hWFKY>gcTl0`~M)M0g8Qx@;}2|D$@@iVR(}Q)$(_o%72=pK{QA5|G(zA z08{tpuTit6!7bKRR{W$Otir@Hw@F9B}uSdp>*nj^aaTW0)K&3|KL2WVKUna^(+ zh#@XFduIDBcpH~S;s1?^rAE#DFY3>9?U>d1ZBB7<)|6x6o?Y6U=zQ^(h6>iS@QbM(IW1g>W-xYr6G2(dy1HzNehI4=OQjb(L_JZJG_KQmlctJcC@s#EN*C+ zoAbV>FaG_zi}pkjPKzBA+9|vG^iN62zziJjCBVtKxCdxUE&a)S14=39jncVElvGqs z##@}SY+HD2R@82$Z}&Ba{k(RHezFroXz1gA3PLmg6#pgky$Un0C#Fem_VtxShe&9v zhT|g*R%{(0;uEYU)Lsr{+P}a5VeUk#ezW^T!>(5FaC3XK&Tki^}1RUa=$y9sCyMgd`*%_$2 z*1J&4P^_A#{tWxVMKn7-in?3hQ8SV4u9Z`sZY3A20S?SW; zk+UD4i_vQIaAnGK z<3LPkCqfVP|Gt~j+l3?zIw2lweU0%59kRK*4O#O#pMA8yrapiAIc~G3(p*^AGB6q$ z!xz#X1{mL3fXp&*wdA{z;XU7%%*Wy^ZqyFG=w>NF*x3D|SF3SexB*v`D(dv0gQj~g zcJ0$+fY>TK&sbKuiafu`QAnt*+pq1xhLh;I{9XCWISN>Zvuz~$yK{cj#%J4O`+w1? z9n^Ilzom${mQ7HsqD({}q}gVn5*h2ypZmbrID{U&fB!OweC09mk@xRWZ$GFkucgT# zUMh0V6|xX(fx@PzFKt(`>BCI$eWSsgB`9ZtP^4a~>ZKr@{Vg_N(7c`B zFNZRg$;q?@0%4Wt&Lkcaw+LiHoAutL6)uG5H9?_c-^aHfBTIEeXfgOy2k>gC>aMTd zsy>e~J9j-xaOvv-Xy=Aihmbfe0DZ4rz22)yR-A! z(64xZ{tZ)xuyvOE7Pa*%C4;HC{yVN@Uxdtq%@9B#je4@)cY?y6VT=^v3)vr{ShT87 z0-^=q?sF3Kwv&^SJsKg*HGp6@_lQJiv1p0l83@e9Sq~FIIcyho2?K{$C1>Aa_&OjI z^bQVt?$YIK%m7RC@p#22gy=~;%`aj3hA2zfQQ}+4$=csbW-B1n)gKDTWJDJx@xHg9 z8s5@@66$RFk*JxHV^XYYy9zF--FLafolzpNMB&jCuezFdN}BeItCz9FJCHs~R0D2+ zAP zr}kqyGHh^g`qnMt0AP45ZJq23fQFund4fwtc((e04l-B)CrL5H#$@@KG(#3JB!73O z${Vy9NVxNS){=K;eDq^{Zzp$t;!mbNWUH=$ND!*z-L_0qJ^tIlv5^#V5{4u&PNED_ zQS+ecQswF?=|-Ra>cx}Nh~RXWZP$s((YFLCxS`(lB5lxNPbAJg1{DQSTqS;&$z!eIUtrgx?G96p#?3+Jk$_UK&1|Pc{RaB z3)<~RrA@4|sac35&1&-cZcsIWc6o1J7xviKRBf^npVm-jhJgI>m^+(^J*}AC2>88q zKK|!D zG!uw2^6~TMFn}=Z=rL@MzYZw9d#516ja0xD6c+?G<&$juILPx4pLC)CEo7#;V8tL; zESUZsF+~ds3ZfXVK#>Q+S17w$%Jbi?jLZNV<9q49cpebMAVb&_3NQb+tSP$5v-9)1 z@o~N~0~Iqa z@V8N=3{gineb+GzY0b0S!E5{T9&m5H;s{KpW=bR#G= z^qZd-^DeZWP6a?{XLB+iCA?ZDl52icmbBdLm9ukHr^K`t`~Aag+Xnf>AVX0v%#>)k zRkiE7ZNqH=Hd$9&OUXZ5`q{;0GU?U$D>hdwwu6a6%@iO*+vHGc)CU2i<|(TsSQ|SV z#FT7bE_pn)$#Ex0S3RLq0yi3~A?bzVuZPT-)>cqdNkKuua9oXMP87G9m6a2Ks*-rO zHGO5J6?MC$2BF^=0#7(lpC{zzsx27ys>_H-`?T``sWw32UIA+L+Zf-ru!LG6`}I6J zP)iH_{*;M6V9Wx3pt1WwkY>jeQgGnc9og)Ert2ShKb0WYM*dp2iQZQ4D60VU3y ziO>-njtFvb#J%-eX_WN=O8D=_@RjS(+8rQNI2r|{C+6>#@VrAB=bzvIjN)jzJ)!UA-x&4Kuwn1>w4g}}T^w);{b ziO21!9SMhCYL6LjBxAY@43R^uxmZ%-s=9PN9g_9*QWji7&B$wSmvKIyG7%njZhN9G zc$=bGVHzWRHu`Q(z3F;vR=D)K&)RFnY1UTO`Sz%z4q>dNM%Av{D!M3yr|gSXmt44wS*=TB`IN8@Ayn>yU+ z``I&n>qb?vLzO8wVC`L0&)fB|c|HHSVR-jR=MwNtP8w{(q6+5X^5`Q4VU%v0S&{BM z_~^%Su7I4`kSBnvhRg#yXMCjsWW}?gogitwg@q&esve9tpa6n;2vmb>R-2 zO3zpy=pn10M8#jMM^+WIFOeo~P!13h{=y}@W?3tC(45mu-yD$Xv-<1zNUzduD<>iH zOA^(nbJLBk3ROIt_Bx`Ib-GSd&tws4hG*3KfFD%<<$`?SGKijnEGjzRMr9?enHCOg zg9(H&sYgi~0fG^<6EOVxs!etaM4I}s(U)}^+$v|h@9*Y5sOzrDi66yM!;%E7({E-s z#5Uxy4peEWoo8($bDHw6@dOuBDo+N*~(y09W z?FNwwqH<3>=1W5Q1hlm$v>gZAN|JZJ$q0&q33lOR*M-5;CNA8C8Y?w zmD-(Rd;P0!brC%Kj+e9n(4@e`^3 zQC6(rV$#wq6N{B~MW~Df&jhMKbmdx4f~2`p3xpYF0U&NM1JNlnjTMSDTFNxmg)U9vlGpaR78i zAW!lNUrriLYAI;(cMaN0b%2n&M>e+&XMV@ci{Y1U!Rey8<%~R}s^YyIHzf&cfmNC_ zRmJn$58PY+nQAvj8bmRunzd8N&<~(#AbFI~R0hqt8mc9oq;|$M&1(Pn2h~K=!g>W|I zaPm2?+%W@_7VzImo1BFD*lM|@@cV`xdAa2?*wk=a_Al4Z)OJxhH$5yu zH!+&%)%S6rITXL!QrTF>SbDw|f2uKkC|i$#HymLIP6@RPHwPlVdqR9dW+r-)`Bu2W zA7H#M54sdK|NNUc{pU1YEx;2Sj6*T;U2%*zn6Vh_$F}gRjg4m#hNPGDVhmC2I@NTot4f5YVEU}}Jc}ZDtXjPO#~Qp9?1NB&r=Zn?s;0Z5jNs;3 zv~9+h&7DZ7XG_7dpJDO9$*`A zQ6HB3ZEAyeGykZIzJQz+S0Pw8tfoz6GfF0$>+`VeH6ry|3lO(?Y^CAm0NL@mxO3{e zP1zbDPieFvf;~7SSxG;@ufJJlIehI#PxNEkFXN3av59>eCz*692QTNB z=jwYwnesxUTQ%7ingd8MV1X-UU!c6vkd>^f@)446%RE>U`wx-)G;4#D&LCE)L(B(Mjcr^<8RK1q3L07T@E-O&FMX!g z6;;Add>^jQ=rdxY^XFDdE(mi&>e;hx3CyMVA*5Gb!oTb3*u}iCU zEGqV@?wiS7I_>FA^cfi&TOlLYj@{7ukH~8*0mY z0ATfC^3&u0vORv__Q~!i-%nS}=wXJMcHz49E=ef@)>-;qpLT$L!lB7jpU&L`Hal50ckXaJ4$-g%hD-V<=k3=Oyr#3 z>&!e}1eqQJ?P4H4J{Dc~n`D6b6%@=A>CPm50n^~g2NMJUw>%XiUi~+VRz?GY0w<u7-2yycp!q?s>_;lukW9Ft5=-+~1&U6#7BW0#ki$Xg z-A6i**Zisx9_(5booQ~{KjB~>AWTE&%xfWs2AG14 zv-7(cv*slYPX71zH%<3D#slelQ-`BJ-+3NH7LxlsbDli`x15DCrrRK{ z#oYSJS=<^Ouv)gZt@8-)S7(bix5GZ|EFw^6#zb@Fpc*XN7l=L#QXjTVVLiA)&e&8G z9w0G_PUz7)T#Ab@B;J;~6zj&R3%N-qM8@YF4zuH~a$+NDY``lCJO)9kcguQa7vSdt z8S48=(x_@z-PQQU#MCC#0d+cd)OS(jEGFAv7n}CKh#Mq3DKW^iYXuTiKjOU5zC2Xbpsb9)~amSvT5ssgE1#6&$74cCQ`-_Ct&> zH+CsAA1FL6#NLA}|G!xP@K?=Y@+nsrxq;KI5&OK|K&Vgq*LL8l{yesFyQNU^7@VAo zW-IGgx{>?V*^(Eo}jYw;PlfUjtQPlm-L;do%$!uGh3eo~OMN$#CSb35Ybp zg~Ts&@*wTz9Ma#72705?+&a8xhomcy%ma{vr(TAlTFEQOd2B98d2p`w!Dvc4sJ-h~ z0%XGpp6++$#jwJ-W7~y$vI)@BlbDDbYnI6R@XVGqjEgZ?aGkiNg{EWvim<$IXXvbv z!O_A{cKD%Z`1i5-z0k85k3hp!a)yv`WQ_NXSCG?2+5il7@G2I_`1ClU^$l7ogM2#I zSIVn4d~X+4+eCgCtyKte&>?N_;^#HOamfnQU<$hsJRa)CQPA*PWzCzAg45u7DKrZY zQA_?GkW14of40Cy`kWY;)i!1TQSlR(X}|dU-;zE--mm~ny-gbt*71H@ye_1(FF`3X z3F&N~o!k^|WjlxbVKkF(nuh6DO!Z;FB?C?|fS}LK*4Q^(kCMsU4!KKH_S)9qM_iq3 zM0MLt;-AC@kr`V0jdmjKhcLdM(lV%@0WJj1ve)e{^U;~U+PuiaYRWi8Ct2Qkb6|N% zi}iL;xAAt;ecrIohfBlX^*49qM`QTjXUD+nkG3{Zd}TyIfT<9G%BF?RN>%)WFoIBU z4=9-X!0n);JvvvezvlZu_#iZ|Ckr^0uYpM50M(w+&FLZ zoWs|Vqz8!EkYQkHdK0PW!!2Jfx}?6mHPm z>-80=$gG>s8}4TBcF`!IbyAK_{6FDW+%qXMg#M)>fd0{t`GY`4W4JomP?6LyglwVx zI*HuJCy~o2$T{d&a=Om@#9%3u;5U*65!HQ?)rT-_d*&9o)A zzvk06mO9JAFQ!bWt8A8}4v3Ij>}1w;UqKHwVXis}9S7&!4>BDq&$uo>O= z6SFDdQv-iPtF{4exIMxdI$1BVg$dwY9UC!UeuVwffXH%{SMPS0K1Lzn6E$o5PJ2CY zf2$(C8cX@Q0m_~_V)F|huo22Zd8s!gm6<2GgPy$Qj!CL&*PR&J2}VkXS~z7wj$-d0s@)S zZTF+Wg9gKgK`RTFMt0QFSl}l%IU9;b1j(v$hqKE9l4ShLz3s+SC|7hM3iAKy0ok^)cU(C4D+q+j@kmy05fWB=_^u%VV0MjS5z`hS;KQrjo>d7ffi`KMH@ z7w6Q@Lw4{T{rl}d)1O(y4SBBFAGR4FuSL3IeB~nkZX_*>k9@31`D~#u^0zCq;S_-v z5JsQ~Xn|^N3!epCqg*uyI7WY9G98@cL4F6;=M2>z7b|7Hqs;(VFO~|F24Xc&LVbDQ zeeVv)h?xi%E>d&#%FU7K`_I=U9$)h}lq}Qu33-2=UKYLqw-~WwLt91{mg+vjrJUDh zM-q)rAnE1dMLN#B^WFC;e#Mp+q9NLC8 ziF~kJx;T+Oj~rDi{z6&&@Hjx?!^7>EYQ*iBjSm&H!0o_pV_rqS6|vg=$#LwNYMPbm z{8(E2LcVrWE2^_tfTI(J=gKRpZXW@9+eb&ei&HCot)0yN#cc zv9zP?vvvtI*l%THOrER|dAk73&ulzfoGp*}wEwg}eacX;siVWyHf*-E3EYYV!Xs!8 z;gMJb_Yr|eP!3FA0_(0<#jZSQQgNQSLXY&DXfo1#IlZKY*ut$rw9QY-AYoeG6`t0u zX3 z=qbZPPt_jGrEK8V$N5T+nG=l#8M}A9Bd_^5OL0I>;1FiO>98hGq*L?yqsg}|HVvF{ z^MIloFza>zz!-rlHWrlBxm^vG$fZSs_Cp9eO>eb%(vkW9)!doKL;3#yel{8mW|A#r zO|oR)DvUkZBayOX5GngwV(db;$d(Yw5|R|M@4H0VmuzK8*_Gv7_xOCjzwbHc_t!a( z^LU)*pB~M0-!u1p-PiSAUa#i`a0)~d^7bYbjCfD-KwF4lbN>&vC3R9NcN*QqiR4xO zM#$^SRR$XG?G?V2^Y>FMeth^ed`n3RfPP%7jl-=j{OLbsWeR*~1_lw63ITI(jL(PJ z%&=NUrUDOtvCz3Q=AS-FOTP!nlEtU2)1ZrHtr`Oti_TLK!`HuUikb^Kp_RGW@L|S+ z^$XYA#3acLj{r7An&mNGM5#@(`@+UVchk+z$ot&LvD)4F)C$SI!kaeDM4`_DPjn&- zu0fs2GYItAWiL@sZ!>tS71Gr13iMAM0*)W%=NTq5H7}DJ6&1R7CJtuvB7rBITawwh zl;C|)3~vouY--{$&3VB_{t0+;L80a%!ycujm3qm_bFA>k!E9@f?+jpV;@)*N_g`7Q zDRFRlzjs>Hp`n3XDsx()cf~3B*SPVdxC231-K0fzT%+{4V!s5Q)#M#Kb__Dm7kO^@ zXXRqQ+|D%aPwCZ_h?F_B^Y;Uu^2hv3G7qKSu`F^LJ3RWsH2-YkY%2S(h}BNf^;Nsp zvR-^?e?$&57(wLsj+?8I$L)-d?U($fyDwhSX?c66*LA|tLELDry-7EfwNiAg>EKXM z-Fk;ovCA%Sw};3VB>Cf=P~O{1!iz&rjJ1LdW5$*$o)^cAf6qr8V|_Ez3>tC58qm`e z6|QTuKN{C48g9Xjx*WZk0wi&!;_S{%xQCko-Oo6D&hvh3IgQ-V8TOE_bf#NBGsM&3Glm(z;Z#q?vbRvj=wq0vc&8@aX zQ{Hivj*w_{#5C8hDdzjDkR^a6n^rpV!o-91?o$Su+);9xT8zLv&n{{koPPGloSO#A zIi)^oLcF1oN+@dht--f{TWtZnkm0MkbNM_{Cu)0pY->jJgRrz{NIYz{x} zG!pg>ClB|{0e{9pxK2q7e~R*MzI8b7;S`oqVeym~(KOSC=+`*jP+B^ru&vdRQzXL3 zm%pD;Lq}WSwR38SsY^#=mhHoJ?=g@XnNfAJW`fN1780QeFa_!o5Blp|<^jfF{HxtO zGckCO?VE)H&9_#UUUAK;=}!9Ev^=c$t|T_`h$gNJeov5o}R1#88cqYyvlFcfPl@_e+YLW)i$uPeDmHk=G&t(?eL zt;uwO7FLqo6$|N0w2rroBYnj<46~mZ2oArPTx;-KM1|@T;Z4oFBLUhq+V2`RM2901 zWR23B>iJK3V1$f>(a5p&1hUnz`@j{j{)6){q^enaT&l}kS8iTm=gO%r>Hw`0PA2b+ zi-LEjYd9~`>V7Euh3nT6l)9MrUXZhVL#M{JFL>t zz$xEQnh8%I^-W^8bv?WAmF^sk7-9h!UcEd=?9LpU<|x)z zW&%EwO}@RebRBFr&N^NgJbsA2Rib)3&ITp_V_HGZhIeknkDL6H7uDT-dUr<3ftVXl z@)>j5@E=%ZB0r1J2A_9U%zYKj6c&~2G>H}AUH$lSLcNw{-D)yA7CWX?&L_bA!6WPR zLymykiF{OxCm0$fyN=nt@zs9RZ299=PyyrzhH81Borw2WdmPev^1?~BgU>R*BXi<6 z5?7kkjSNfFkw3958v`X@^o9=~RA-4$1l_dnf!URi_w0pGi_nc@4Oy9~nDMT^!6P(z zpkOEAUcNhbd39^RKzCCGrHx3{z|pp>S^QKMyquGh1Z!{v(NSQVhUZ3_zBHtOHtVC4 zZXmm2L!K3~ID)=*Rt%pOWZH(+0a;=8*EU$vP)`yA&7t?>DWqQqJc_DOqp)bYc943V z&WsbcD>Xkk&{?o{9r2780&hICj-?n`xA%D<6k|*w#2J*4qyexSa5Z7IdzEJJyle4FOU+>j~vCpeJ)4A8T#)_t} z-w--US2no>4aVZt(ud^^fv+ejP}1IaQ6PnKk#UlYSMVCQd*n1rMJMC256pSfzIm%r zVp(0`@ouIgjrTPpa34TVO!ITD4=;E|yrKEM-4>2|1lSbGXfxiyOtXcVoKUsEP|@ii z{^w>g3<k+M>tqJ5 z2mKb$eA}0MJ@Z1(*vwtUKhCheAt)qNR$`q^1ooHa1&;Ml`pxfle(V z^|YDY&STKU!e7+#m*^TYj%cj$di+MzF^I0kr*dmjA#(4;sG-2Off@L@DY}$fn_0#t zD#5pzc(<`M^YTpdpq*i(qupDB!HR@*FrBwjdXeyEH%EThWA0PQ$|0naTRNrecsnbs zcadX~Ws=Ie5>S17sHtX;)8)#2T)26Oe8*gnhTWKKPcIZi`1F8a2(FRhzbBfRd`Z0&JiSn*J`>(!TT1CJp>B-+(d5L_%9ijK zhD=ub>1%Y%mqJQO=hs9V3L?S9NzkClfLw70fs4nA1N12eBHp=+#^IK9P$!1b)eQLWGrS;CPNH z?DdX-By-h0<8$jHo=0EWT6?vZo4G?kV2sd8BvCcl0)*Go9ljaU4> zp+w3oOrZb&ai}#2BWPl_6pr_`(wulAYWtz;WiMSo`b5pgdq#t=J+|d`l1L&>xr*Y< zOxa=uxqp}v-E>fyel8u5;>P=C!|``-TD2Rxdu)3t#WL>i!W=}JlH~Ds1tAjO=dnT)*Zo}P>gwu;Yt%~#$Bi_@fH8YHd^7l2aaOo$TVe0(?go$>0nor#V2C|$9^)g`gSDuj*Q{@` zXJ);#kxc9k5fYq<1F}V+xgzPu#l((Z2-N0gBlUVJGmUE95>B^+>_6#_gTtCw?<{*M zHaW~eMdy~CL5W}|E5HdPYL}J(?TM$j&4a1X29x0cYp<~*p1}1nog@+2Z9$I~E}F@Q z_fsNN$=f2W3r{d7RXHV_0s_`k9Bk~@&uR+E^qhno?rgZqTbQkIIUb>#lhoW{cCu~TF}PgX~Jz; z27vgWM}XzNg2>2)#3Ih~x%tP(!MVxQP;oJEZfP}a2fz`u`U#She+H2lH@Ct~WfV!N ztc>D7>01#ps;e90;b>S$OPV)ob2BHDGs|+(&z-aGq&LkcqVv-`Ly|GisE1D_MJE{0 zt@yKfcu`3c&X(OVt8ngsRIsTGPy=Y_w73vZxd&)#l1HMQw5`NWBTINBkeZ3bkDl>{ z;ni{7Fab*WG`U_9?T;|~oTlOT_RDn76g(5lH|=jR`%A;}Pgm{@AD$Ak3+Xl-I+t+Q zX!tOlmdn1dwTGeB;dy6@cf%B`$KIRT1K^mC9laU7KlB{5H2hxS?iar}u#NT=cX($X ziVMdu@mEq3e+NULjJ2*b%upJU$mB|Hq0+_tz2|LKodgoX`iT)>IDe*(zrWnq0qOa7jv+Au!IX-A~jG?oB7g=2mDs~UICAWG) z(HV;hzv__H3LbGYB`h>9^&C?`FRSZUR0l-6(rsK#{&HI@8JuCtA2_2WkSujM3Q8IF zByp-6&`A$ccQ0L|8z@$Ydu!Z3WD64$k&y5)?Tf(-_0=uQu8%)qG*0-u(zzD0T)J`69_9Bhzjp4zI~Cfc|Kdv0%Pvb-z`T2f1h4Q>PrQ z<)W+bH|x0;UpGF?$a|zj3=Ki9>Em4wVDn@gF)w&8lSASq z9l1?IzdZpu} zv6Jw5`V8%`lo-s^Q`hliKZ}4S_ylv*1b3v?KdKw)KuH$_Lh^r+k?5v z8n7qq|1*AOK?d)?kC8zh1W=v3q@vRTM|A{XnWRbOvdg3Z@c${GhMR%exU#He@XMFo zD9cA|mR;wZ1sUIJhwjNE>-!sWF^LQ=E}Ux6LtA+S)M?>HIJfx&0_3ja$scV(QMSFT zgp(|b)%#n95OGM_XeP`}(6r#KVRE%$OuX^djrvj^9}A1R(fWGD(ANc~E1-^}?H=^| z*RQZtS=Vy&8tsXApk%$y7=gi~PM$m|5P&fb0b&G(sy!0J64Zr`&W8p*T4*U4qEDc4 zx+2y$G&H-o#Bv1~AgCB#@*)eaNw@qP-#FsNr?VN)a-_Jv6o3bTvCfC!HrR+`*_2%y z>{z9xZ14Fa4nqYfzMdxYF6Lk*X;+537SR?8l*eB`e@+ee0EE+T?fB@n zx}DvJ2_!}{o)^u;48YttW)>uC;H#OOOZfw0iaaw z?(Vx*kP(s zgkZTdZ19nq--Onzk9DDgs1&@heB~Pn(65?93b2zT<7H@$F|C`d`7y1H)pHRSZLGl{ z;66(#c#wj(ND71+0{f@9dMo9^Or-c2Ks(OU$j-(%TS1aOX}f+N1sxjEwty30kQ6@# zv)Vt#M*KXQ31=a=n-y`+RFJz-1;K+F(snKnC{PTlWPUH2B&6 z>+TV?a2d9d9eY1sKA$KIgAu0k3yM0Z8F|Qn>Hn+?w5}^3@wJE!IDbru&k~S83@^9U z0nEV6db-Uk2!rPP#O6Ow9fJPz=g$~8U&U}N5yC($uwmi=PC2_xLp+96_Q>Huh=c{i z2u(^~6T?r`!!pyGz)w)TTg`6w?{Njb85R^;GiWTB&xK!a(-BS06?=rqR@8k+dd8SR zunoPi>wy9Ez(9~>(GFpIuY#Bbdf~ftjR+jPdM>;GvpC2ZL(eR;QucW)P1bcU=p&f4 zu9=&!9suah9PjC#B+*98#wb6jO*uL`8~$h*>;w_oBLFRFYq5`oZ*rVhsXM%-jQXf) zC7~5VF1JhKSrNkU41_5N*Ax{Mbr5zlkQ_$i2G1ejv9)4^0A z?IX%LJ@H@QFFu=lkb5p1wz6Gl^7f*gzuvFsoWY<+K1wUilv&3<67tmiq3lAmP%tNj zbCBOacMAv7jaUDDt0YJuWOuIuD4q4m(v^w@Gl)iJHlMtQq#|&w;bgFC%RTPLi?r3Z08%N*9ufFG5Nk@iHXd_cr*{Wv$_B3s*zz)%(tUfezBD*9 z$9i?OFBkGt{{ZAp&OZgd{gk@GpV)%euiYV=r#{hvFjY0VUsRBn_j|TJNJg2;Z5gNw z#5`Btef{%z0&0LZDmUU+CrN6T3NMahuV24@tQ?vZAm4K+*bWoR>SOYeRbB!fj=0mE z4?-E!;^K&q(-O$Z0Bb!j@3bT{htO47TMH+rHc1JIYx|+a7}BLwLiNYN>H3`|k+q;8 zaYIux0{A3dbo`$?w=kQrIDrWDe2{|Wr}&)K&aptq?U$_3qmLGcj1dOTydpP4H?vf@s zwgf_>ygGHh`e1v~Z<5NxXDgRkM7seLAkyik0B7Re8RRssBPJ4pYu&*mu{#0RffscX z&;&hQXQcl*chDP$;ot_gvcHJ@$Cwy7N)uP|4uRVhAfL)u!?guq(f|rG9V2L3-c|7D z#}9}o^$^2yRUVKd@Rv?K1QMa$@IXNgl_^L^{}X^;J;BoP^IL@QmMh%s!Ee=K4ZJw) zN8Pxi-(OP&x4JQ6h3i7)i^u2raf)q^FcOmQY?M&1g!(Z@_EC}DdS0dMWPDO{8vJkC zQLyfhWfDdc6alyhpaj_W2nqv#U%g=d`f&M8t4sV}B{Ba#+bbCr)~698@CL;aaS4mn zB#zc!@YP>nd^L<+_*UZiQTVHk26<^d?U8)pbTG-M85V^%BGjQo7xw}0RX)+;+2j} z_&S^7q17`6ZH@s{g9`hrHayqRtOg)cQmVnk>eA2kDjYs2ob$I5%8<6%VJ+60sOQMD z#5~guEfq__zoG~-8T|LvfYG%K%|=D}ZB6Ds8_7rk6XP&%}b{4oMD)R|1aZNr;5wZDO8a(|edDGNXT zf)lXfRziTA8+Xt{=aWBCjJ`A?02)`7 zmv0%WGrfOb?EL!miu|NUq^L@tps|XO9j(yn(xTBqdULyhy!6Tn+wTyH`CjwcM4 z8@>z-R059}@Fo`59_ZMMl~~k&^CIA1GN=`V9gsFHXrX~~qc)B+tG}0Gf{ULjq`0)E zb_&;L7Y@tO2P_BETVSqAk-T$5U0t2O8|K}bV&q?7M~0zuq7d2@u<7hJ|88~x8*=+Q z3E2tInti`gMbKo;ULW2+h;1#zSkiySJYk7$^G{?*G_{>2eM%%3ePRh`G*k5krU`

Mbu)?4WfDd~MKDsZRc-Dfz>OqT-{I1f)=aoR81!&(Zg`CWHt zKz4gM($K)8DvlT9@44t}2ywMOIuQ$Nr4-&MWl1apm#bjpDg;<-a9_5#c8AOsIhBQx z(zhXMl_S;Aks2;%p0EYB0PH()=tINSkU7|SR{`oW%e69=dGH<@a{aQh-E~NPd~y4I zetUge_{-Y^7iuEIV|m`CKBY+5w*BVolap4&M9_fm(pDoG7_RjF`-_vuLSK=ObXq)0 z*r&^Uw})}P^+QP53k}q3oU0`HLbJ1^nrRBdJ<8a5iNO}b=|z5!ErY^kd|`*WJ%O+6 zlGA51PfVWwARqv>_{7^U?QI_4ojWq{U6M)=0%+;Y8wC_%G%ey!3Z%Rsuv&sm0(9N% zlHv4t_blBL-OkoP{I13WwtyE$eI1{(4u=;zEA6zJy=on@J~Ns=*pKqeh>NCwBU+EX zm8PN|g188k(rK6qDY-D4>hLet@8brzPytm#?oOP*+(gVR^rLq$Et%I8GEomN@cL7^ z=gbhZ1~XhNcH8K0pFVP5EynJ~Ni$n@A7^LT$0^l`UcUPaD@AEM0pPQUY&vO5Mt7RS zS)Ig4mJJFMA$m`MPlSuaF9$U}IXny<-Vm+hPmXf9L_X%ae_x76_4&e7cXNUG`2G4K z#e9d8&|x89_GFQXfa^|-Zvb|1qh^48hUasCT=GMIe&W-RCD{ET>O{j)$#hD=uU->k zv;LnYDCwSHXhea&Ee~&SRPveA3l@Fqv2Wwny_>E9b}oXlpb6T-2DrMPo_{xT(L2dv zc}CKcVRX2{XJ{r12RBLI5YFj5*o3I%I5N0HcOMDkYjo(M!!(F(sz4_vb+g?_P?6IU z-=#01B(dxwK44cLr{O2-MsfG^OA5!^j@B%hmCN7QWp*{#gT0wGi}M>KFp50E9*H|H z5k(4cFp!twE!`LYFsrDhsBjmNQfOGVXHYIGX7ET~0}l%Jb@wU!-iV!Eq!3XvLq(43OFx(4UtR=Z z$0?B_3&%^;`dzi*l#izh{EDWE#lNRPH``{;W=n@dzCfzB_YVzE@ibxrsuK$}L9{!^kcm&~IS-+Iyw~$=GIg z?!hMU6Ul((9JJmT*EcObZ%WbOsB2NCszu4$et(d6z2Z3D*iE;m)>gN-q{Ti8cCrmq z%ztxMsA0VlUbiT##ymgOeom0aQzi)OxjtH`p>}1LK*pQh+xj>dZyOF2cndzCX9CTK zhb)<21DXaTF7lkj>2h1Y7xyS*5sb{(uf?+}QZ7T9vkK}Jn#wk;?*W0_+7i&mwF_#G zXSqK1?4+en&s#k7A5Ix~=HQ8?tSh5z<|v-*uTy$zSP4{>)Gm)gYg2Muwi8-88eSIp z!ZW;1MY=aA`I5c3I#&T1DID`S+3Eqgg4rxG42&|fB9TWUC37yaMCFsaTa%Mw+ebsT zOBY|zUA(4Bg%0;TsrxX1aw+MEORb63O-AbY(nN!7zr94~7R}S0nWE&pnbNJvi2`$E zlG2y*`VZvYP-t!K1%jlzWn`z9%ZhN$;tY9DeJ+q0(?JL$|$I3@S zCE#tWIt%0M%sYH>?v)knJ-itiGeU(ojJA6({M-<8s8)RkfQc?ddx3%Bm5 zx&xY7D3$0^_>hsGxK^W_7vu($5Q=BhkW-gjyQ$cIVtm;_#O<=1ianaZtfm{0LlFz) zjOAewHa)^Tf$}JsQW=o(Qvi|EQuzcXTC=`{1j;#A^L$QFrDvhBV?(7GgU?&unO57p zo#}?7xW)5c82(x_EJ|sisDQ1*~$MaxD#bd+^6BAT~ z;KCAGoyLaucCGE`!6&<<2+@`%$01Kv)$HKQme(z1sy?|SXKBjB-TiS78NQ?$HBT?A z-vplyzwVCmv73nuj{K>HZiejxM1y{_>5yBvJe50yzif{`_vlYu`T2u(R)GG64)6bP zA{^`7NPXP0k&OfB;f+TQ3#r}|-ADiVE z1e{~?$lunBHoeh#jDL~Q9?BnlXZ<_h{kw}cCt>?f7m!b zo|VQG^J+s&{^^GP^_Xwxf4Cgg|0^45uEjAgi2u`#`IqQ^EeQ9t5u7AZ+sYu*siV^>0MlkdTISFPg{d&VVg@T+^4%vxS&7;eSy{ z(LDl=BZ#-}D{!^69d^YEB-c6pm*xHt-QWJ=8!^B~RPM3AJS)L4 z@I$LTzkm6~6}+qPLRQV+Rp9@BBY#QJ|K~Sy0XNWSX7|?S=ql<=1t{-Q(4y7rXwX?! zL4iB6>%X!qLHQNEVw;@0zWAw8C#L^VT3sHK3R|xws^%+;9WilQPwF>75pP=|e}p>z zLZJWgl??!cbsyhfhjILhVsZUPv~DEBg-ld6>3a?mtJd0B9q}H16ZbRi!WX${Jwo{( z|NKv0{DnuD8gGo<@_qSMJbqF`Nw>*=g5A=T4u=!m=xD;xi#0<>X>iIhJ?Qv=gmb5X zaP46F_h8(=DDj^vPm4ofTpGD^l zELZ$+p8gi@YhEpzlby_xU)8zD%E)j<_WgIw?TNY8 zvHzfAwK;U)>L$RkEA(0$=(l72rt?-^R)LT&!BIUTmm)T+S2iXS zuY48r$NfQt5O^@cxW4d?@I@Z;En?yv>g@zQ2t9$FA;*7}Crljx#CrVsvabIEu{@JY zokY#I`(7iDVy3VH{<=5@DcOdH*S_;nx#*UB*v0<&+T5Lb@sawuVVTs{gvUD2*v##LyD0h2i}nUJ(Zt3jF*mx-VY5NDD2%P3IOXefpQl zM-wGlc<&xXbas~PTSdnKhO5`FtNQW!ZmWI7t5v;3C-iVZ&-CLai#LB)aYbAPVxL9X_lU?+vXfzJynst0cmsnt)L6D z#b)rzmqf#{C5I!M{~Cz7281`!I&GQO-U$EFGpg_d3`Rxqp&;J=c`IZel{JO;N5C4f4cu|VNB%u8NU2eeN_T(`b z6*vTW8Fl~62>_A^@BkoQQXXtpx;pKDuqVKkaR=WB_FuaE*5OJJf?lqHlHD_{p8xHD zMu#jBF8%T7P1Oyb{oWQhQ3D{vs4nUcl%K66Zb=9{h(QU3C}Nez7(p0*zr+Tm3~+Z`p*K_9GaHKjfuyI!8=o~!9Qkbv#%fNT?yw%Mu@9^~2>Old zctOC+HC>bfuQosN56(k)!uYHOZug2M0E$(5L{v>j;f+e6_?M2QJ^^HE0W@)CI=uV` zYLM}gW4=w-t$b+@|9*JF+TdL?Qswd~i~6YRzk#@$#MNaL`zXMh27f!E1Rz}Tjn4H! zBPNz<%7Ha%TRCN z9&+*FvcXvrcAxL`;xkrOw#NyJhZhgDL0Yp!H`b)Pul<)jK$8V1R@nU|!52W>nCc%W zmNQ@|PxO2u#4A%?!X*x1!WJD$G9mJ816-U5sp zLWmE}j_K^Qv?F8}Hyyu;dMQ$}EdPSHx7PqMy#B%eAPU%hy?+92fUl>ORYitZrr(g+ z%6k{=4`|7K(*D2@BGSk z+-ZomQu6qgf*l~TJ-E;w3kMXyt3ZrgrD#?~W-F+QEbBOtBG zAOFb}vKZ?p)usH$@sI-C)xk7lZ33{O^gp11*v~b0GnQWsJGf& zYcQ6If7%~qaYHWU$wRa5Ap^&5O1Rl}?XDGD_`7$}D)fOeY5d$~bG4MCyM-yEsUm4@ zfLoac79RfNrf23M5CL1@xw?2L{RaY6J=d`x8(!#mV$NtCv*e|&9(TD9x5#8+Y!lcd zB76M$Mc|XI_Aex^ae!!K4I4}yJ^9hN{B_=(nD0{F9QnHYAJ;34SXc0B3tS2OBY54F z;%IiSk~-|j9ud3A#M<#g30XTc;)5}xNen#{?y%|K+BeiFQjc+6^j63qKVEU-H0e@8 znKN58-ks&nhV1fe$zupVk)WfeUp*`}SlOxClHKL!Hiaz0EA19;xL>42ny&UuFg6El z6rYT#+w*`)xRo-CO&+bAbQ z7-*l*uYSTCZJyy%&5@I2R?CgEnXi{b=)&tBXqPK{zvsK~hcW?K+4`@}B}X$QO+dTR zomiEQ6EAnqfkLP4mj?*SNrZu+)5Sb-VEK2C<{F-9J*$`Kx&3a*>R#e5`Nt?ox%hod-3n|7s*jdg-2 z5hJ3;gdwuxpr!i3x~!LWB2w_gz`8)jHA)4htX6(lSDd(}LIxo8Jox?|WN~WMT9N$ z*~7d$Pqg|=>h#&0Tp`i&O{aZdH3Qm>$WKz$%M2Mh`JBGxBpM|S3nOQ}PIe(1p0UUE zdvc6uZ8|U!PVKG;Z&_>-whaM$;m_UFaTnBX^H}eYW_ai|ZJS-zIZBZ9N|SEIhUU3& zt5Rm2%6h@aXDS6+Wtz#$YoV|<$gq&e=(=%43jB@q9^r{RdXnu{B5S(a);TU@U=kcO zu-E_U=K%uK$$fq-&I(Nx+cNCb}tWx`H5NC(-#m6%$A> z4H5IBd5MUIXEHKTK$@wf*2XDBR-=yUkymg&_ArpP_Sicir(ZL;(7CkmNuAZ0DvytO zPd}&27A{iWg~=dX3_Yc6+r8-w*#h#Orm>nROv3vFZ8$N6&p&!}p6yYyOuOFUM@^{V z0vbv^=jScuSA6ztG`@Lk{+>U(Uxt@9ka_Ts1sH=Kl(D%r%fe*v35%`BHIwCak?l(U ztHY6Qi~&OTGXo&Ky+5v(D^Ace3(x!f`X3@tZ*?> z;DBhlTvpexA1{AfZ96A-mlwjrz@S0pd8w0hGREGhbQFXYSma45?4^CmtI?rRvn!HV z>flX+M!6*{-%i#n+6IwH{Kiu z-b@+e((Hgt8G#I`$sh08xW!u|rms@&y*vCwuQiD0C)ucR(4GuSmuvOV35})G^ zH)bG`?Zv$+Kz2GW<1{_nYLhgYSs1(COT=6j=`Ak|w~-5M4Z?GO76e`g@=`>atO&@+ zn2Er5fy6wPxP|qE3sYd0?~qEomvMq5z6EaU9(G1U%XlS=f_$i$Xc7--eyYcJO2fkusH zTT|3qoo`t_<=I1!LRH3Y{dW{r0@gFe6+8`nf`G>59mq-|Zz}lg(dY*;1D%s8uw#D- z)a;A_-KClRs&msJmWQ`Ia1*xq#=&N{sK^aZ7i>Tt(oG}i@3r{3Y+JZSiuQOdz` zpnTdaHzx&@`wZ$=Pz>}mf9AfmNivo(8DF76;4zl_u+rw7WpjSh>&hQ%I4VUbPV;k% zd%DmtqIK$JOg$a1DkyPEEMgEwd_Z_X4L~c&MeI{1Z6C(s$yjW+r5MlY`IAUDS1bImd1C`4P-(C^PHjq4bNjYtad99gb&D9+RjA~xvr%?j1p<{5;&nZ zu+rD4(VEzOJU773m4B4@dY1vsJhB`S&uy2zu9&~K#UeXn-KnJqhqhLzS+2XAa(c7I@+PP1Fm{E~moU@mGiOHWZP zU@Fd143)PvZ%e|a0i0ZIOh*PpDu|yrAxqWbn}ONnu&==I@f8?3g#S6|;vjHk6jp+| zKVhvNPZxl*i9Ppnz;>>hGcxKvDPmykdNzQGW@BqUE6LXO2Q_^0_#e?an+#r6%=;J_ zftrX~v?e})7wY-|CK;RP8-_SE*azPy^{%vde|dn$tXEr&7f|HjR<38>;ot%ng4@OK zX&K};sm8A$oxNqhxvnSPy7N$%#peO+u`$DbUp#AHwyWXPRBFCl(@TfNhC#FGuiY+s z8aiI*A#KO&%1Gf2#k1=_;zMu8eg-#Y{~-cdmEGv12i%U$j>wJnio)@EHD@P-WjG9En0$z!(cCK0ia#4tIaJg=YUW>b48S!T={LR-SDy zo0aa$2p)iKCVmsGoMsuP**S*Xm)F-X##?HI4@oCQ(0#&+%;W#)P;+&0m(TJQk%FBV zN%2TM$aCW0?96ZU2-cdU$)RO?RY$b6q(UDg3ozNr{u2FR=RgKvsw-4>V+|J}FK$6D zr}lR|9Je1%6)j~KkP18Zn2f@j?^M3ty|L-idGNKy&KcXs{}ygrqo-Eht+NBU)ii$B zM86gflfuJE|7h4z=k*)?J2OWLBxI*o)|^!HVX^QcbO=#t+RF*&8*jrWv-|uj zxGDJH%Fs^y$DR_2-xA2aP1fVIezYJrjYa9Ew_$6>ftmv;Z8bJG=Ec&*a`w}J$EK6T z4Tl>h-F~$?WJBCqMr^L&3F%t9IW`PDinwI%!Y!7e!el3NJ%AGHz^}88x7_1P?YswF z>O!#T^1zoxsTW$fKHm@mDtqtCa20eHY2`^a%(`mrZKg?PJq{c1|W#gGwbM38B3 zM*rg$aQVl`e)sh!iIf7ay{lUD->ehWe3x2L_^9RLnC@#CzIu1OHDN%V&S{p63$pV^ z=r?PtoFDqc9Yccw}Vcuw7iKXNa=t z7%J#~*i*hP)u*M_iZh*t6{rM%9VmFT(~mOe@2DIutML)>YLbHNY&zV)qgf#(fpaD4E$qwyP8BVJID7om^MCh!BG z0U9*zF|}Pi;-x`_7j#d^(Q3+YtsGC;=U_&l5v*n8sV023{}{}N#4m)MGE%qD=E%jz zL2?Jkr#9uC_kA4Ilh7WV8?5AUn8%OaE!i)(|_;$V)|ZU7Dh#_!QO^0P?KU*!SE@?kE*Fz}2k}73k zrhBsmFyT49=lfNwEeS!^$gAv;k;C&md-m%sZv@I*DU!mMICJG(SUnC-AM#S=_rWWtMs9LljesHZ}U%sFj~G#Kryew)51tOQ&V6NR;5Nd#pNd~l=0!3!WbLt zDnkEVkQ08xdG7Xc)D_dX^ng!xYwnXLRLWI3D zjoq%wvFG0><~VQ0R9YR0aN1o89M0K?-UEzM^%7lK{%U*r77xRm3wPI1Mo#k`3HZz< z;miNS-gibdnRZ=|qM`_*A}S(cK@kuTmEKf91f&y?8WrhPdJBjsj3R;xqEzXSP?X-1 z2q*|hCm0?LQC>pL7WlidB*pB=l$0A<9mM0S~F|LHM#Hmy3W~WpS{mHD+PEv zjwgC6!hSi=+3m~gU9w20-eFR$11Bn7C_R>Po5^nL zO~_RPh>U3T@(NgIES=P?rL8>#R0z)nDWaGQEs#n`fg{H-lLik`zwOGJL}UR}lzj2m zD6Q^U%gycLva5Y_!|#`g>?w*ls9~F{E4My85+lo=z`!93#5~stH;#PWOTMl-N@}|) zZxsohBW=LdSus#S^_OVQL`9Ex0xqXrZ;DpF-u~LdX}%q$C|0}N3H8Qh6dNlR3HwO_ zsFD6Izguk|quvN4VnHYg8h&r<&h#9$*HRGrD#%HPyo?*f^G&FjeoDf&DoBAVq`=5- z9CZNd6+7t;zZ=b;ccWNBcDiLHx&8siHEm;8SRan8m7%p2y4$a=vu!XXQgPz9;+%xJ zOIwLh4sji83>+_6PBYPICBGr8bQiEE6JnXipjW3kTx32kuYBp_j$aR;@U;M%`HutW z_CUmM6Qs6N^rUyx!nzDV@!)kaX=NrI3Sf8$-uo1!%gdj|+;0vPkV~@=)Br7M0gxh5 zTn@lU$v}3{`7O^O18H$SpEGQ+276ha$2@9tAFt~~sl_mJ2wEblT3C5w66l5}I_$#Y zI{W(jxRRU3If#DKYF1sj886Y%yn|B29)j8A=b{vQtid?|vzCwe18b=~tvmz!b6-ff zS}#{8ZvF~~FH1g`Y19A~xQ|P(&p4C~Z4=LNb>;yDmJIRpfSlQIXvNkl%vNEzUPQD5 zO3gsL>GkF*b8So$l{BQ|If4xx8f{4$e&fJtebxx8vUP=B9D_kGer5MuiV#AfFhMNf zPcH%I-Pj#Hsl0M|1%(VbKnOFq`Yi%sEhs@@XPP;Aw`B9SxZ{CZ=mSU2Xm&Rb$*Rrb z5yJptL>;|{QC+ao{Ts`9?f@X9N>s%fUsCz4mx0>nPfz}TQ@4Qxf(N{kvmWB($L&0t ziT8J1;1t^}2(NT|-TZbcP3zcTC9T?O2G_W->;ME==CkBfx_LQO)zC$QtmBXfcZuXN zR)&_{X)`xgSkYMqnj5vCz6cv#5mqNb(2N*t;^dQbn{>O^Ql{p~s9)j$f+EXDwU3S& z4EB9KZLYbn6}6JDb?oP3MCo--8*Qax3PEiw?l$>S+_e=3x}sWe9VJ?0A|2k}6XX40 zU}PYfE@9I;p$BL=JYHpu(EA6iv}K)ue$H!$_#2{yTVwUIac7F7V_rv$s}QY3*kMtq zdtF6V*vke*_H5VM?%KKGd-(z3!|2htlb+LW(m(9TK$|-H*$^LvA%j^!=Iy)SCRrYS zg9}enRP^fXU~=A2ZWN2f_fFRTnp6Xnw*h5x(^*#@d&!xwiOVeRJd=L`^CGVsv&(|Abi2^HR3BmJV0n>DA)5xiSDw$yUAdxD^KqfihT3#d7PLF z6$X>@3d=#qokCESC6i@}#gZ<9bm=2&mrIb>(t0*uPT57%UHs|x0`?$ULs0tFKWqrX z;1!N-6F>*i!Wk`WcYBwar|hkEk5{68H(%t8T;8H4x`~CcugpC@^LmLvb%>v$ZjOm} zsnB%!H|>JQWg54b)AA21dzx$^U?LW1g?0=0kB(YuMpSlvZx*|+=}O4|^f~{dp!(5= z`2SQv)tPm}1Hjbx@2h|B=H}x1*fN+j{PeT8{88Z4AMZe9#Sa6nAotdia;OwrD3Cw= zAd~qyu!r>kGIgX-^hK@5`s5pX3-Z|k<|lxA{Dnd+@ch9zQ3n(8yjz|lXokH94sh?; zvu9XHerpgF;O7nsQuo=jXRw;3Fz>Al`_3G->xoU{{PK=MH`|seUP8KgQ2g(H*WHWR zpwSYCfcE(Xh^|>%sAX~zcRm% zPiGr(@&_F}qv>Y{kN@5O-!TeA2hzvj_2DqUU-(~;<6#|vEc^xEiI79F{Cl2KA=~@% zEOK`FA>jQAkM=*kr{-#Abl|yqcYzH*kloLR zg&tN4>+>}}y+TSMmplv(eqODnFmo9sX}64w4B7|M*(E9{aR9#~aHKM_I&3}j{Luy7 zAcGABjsozeg#U12Rwx~K%ome7Yvic+qb_pOmdmVHRh1$ZtE}iy2@ADE?)|_~*jBgT z)9H4$yhYqXrR03wct1Qovc!3~7D#(OO}9qCP@)L+4hv%^*Xl2B3mxsY+>N|py8YXy2zk4; zjedaniV~N=DRf?OoM>_NkE+VwHrZ@e3_Vrfe_yU+IVtJ+PM|)qvhqgRoIjWizwbWT zZld$rz^*2%?zVWAQZ3TD@3m@xb`lRmT({1lW zGn^#!zE$bSG6cQ#DIwLiNyxO6h{zSrXi0BW_H7fe%!H@ESF}3AKKpVX)wb&t-;12^ zB*oBiH?z9%7l#VqDi!y(%5Qzc%pt)A$o&Y`#<^tXhX{r-ena85PS9nQAEwSC!p}go zcp(znk0MW}2K4Vx!_1>Of`}6$qrbVy-@dPtp{36&-o(PrjtJ%W?01oVfND^1s$r3g zPT%bwVj%1iW`{D&~T%%Znr9nrRHgBJH&3oAHiN;VV8IZD6YQlwh z$VJb7hv3_wgs^&o5FBy0JaOmFo#n;MSF$Ptr$V^UEO0gSl>5C;d45q7TT&59AyJst z_>Ml3ctq2Pc!dMYQ9XLdXgPW7)VwF_QN006PkU#mf*tH@pRCSVpRQKc*4`bJ2Lv^H zso{hk}nLMcSBNN?~)RYaCq>-1Bsx2fFNcY&X z_2?;Jlje@voH|Fft%H!eX!%nEIJ5)$jDERJqfMJKROPG`-7)%qsd)I~t{c&S@^LpY z2eY;Ib&&HX3hcbEU&tp}#Vhj4{;LnpIlN>0Q9rRgm}V&8Iw@?b@byjaW3>XB_kS0_ z*wGc!%S_`+ACy_=HE51_XN#uuRgJThYiSa$0eKS>6OiAGh-IcLYQ_)CbTam86<@sx zQ-jo*oF#~M71+2(c`pSe4h#&$-f(?v+gE73i&JJ&hf{D^ha}K%gNCi_(%dw8|NfRV zFOr(lwW}`O`&wAvsh)s9yfJm?Pn{P)`535|Fk{+yyM!7ddIWqk61Io^u2=8zK zV`ZJ)u#qRRrN>L)XYqaJxZbT+adMDtgfI7-<|J6FFoivy)z~tU*ub^+OLjy?&$%PA zMIe+fO?5s%&15iBwt;Y8H)LdqR+*m8TlSgMrm(KPoepn|u{>?Y>w*{)|IHuA5;*@X0S)RPz zHvW8uUTcTX84+kq-ZTftBA4Qiar<5AMJK0chn1vkkx&V#w=~OvE)eF41oVeQi#$7L zraX~ge;utvG0JUclvG(s{P0M*Ubn%;!@zaArt(9s*_^D^GF%@Ks6A8$mr1?cB}3Tvi9CJ!v^C#z;6g;9r~~eJ@P2_AVnX)?hpzS=2?sDWI@3m5n;UdX zloc`zbDmJ(aZvF^2Zb2N)sig3aXAqC&rS8PC2q!<&xvXbL=kkP^)`9uWoRsce|?p* zTEmx-qfai%G@5}f8gFfi7IwH5Ro^Nb84UNuqhqme{KIn`0|~n;967iCIRfIx1N_I) z&jktq50TeF125LXzNc9Ap>*|X&Z{S<-wr^kAee&R-!X|j`Y|5u->NnACBXy!z;GW^ zAs@|l0Yu4m+o}v>J1;-l&z~4{=(Ln=+_BF{_`Yo3K!n{ubwlp$mUuRS5I~9V=Z{u; z`?W?6vz{5|3g(y<=nrWF?$+s2+5&0;z!_}D0^6TvwC>^l=%Tud^wXCRsx> z5b=CQtUEJKLJ$xcU;AKZJc-))>IxjbRMNO2xai>O0)}G+{=Hf2q8mV3O}mI^d)w9= ziOf|RbZ)habh)>^&aTiD} zHSqZX>RWjcyY5?ou_>^iCnJ*1D4*r-r8(b-SV_07lW9&7%4!Nz-OIh%#%c-8^Yv!wx7iR!Qv1zEU(8q>6(0jd;lYP@D5i2h z{CWTB?dz^9!v)%?jFPgcrR;_q9&N)RDWG-kl@7Nqg5zQ_szGlB-6}uB4GQyMvanRH z!#h@;(=XTamS>6#%?&Wijx~)lQ#IyEZN6dm@5}dz&n0ME08JE3>Yxm9!pdp#vk*8q zgs+c*C8dj(dJ{X#0Svc(ZLYbDG}w6*e$adA~!=|ljGsgEspcdIfR~x$1@|;YL824b0?vwflXvWnmFOu zv1#wq#--sGtlXX)y0@FLlkr!uPL+llcHkE5z76i_8TgbbTOI%E)5B2CoWn;ZE=U|X zD0auD@kfc*MaRt>v8QVd3D#6s zE3fSAj!LL2FWGs!GaHK{_{*d}YR@t(dL{*iM~;=`jXY#e z$l_$q2;Nv8;-Pj-5NQ&3!g=HqNqFY+StCflh%7yt|N7~kLOYUl{8Do=P+gb0@Q^W} z3R0*y*8(vCOA>69#KzW2W@#1Vwr3cs6rDI|k7N9Md$Io{4H9sS-z-kkKV1OkwFM?@ zn><$(D*Hw5&W}DL@bZq<(+We0ZwFS-REku-Un|*ik96<*_wq|)HR5V11o`u;j4OwP z5h?(v&dPDf8B069y8;h@56jPO(}<-Jo$k~ zCEgxkl#oB+yehQiLBB{!lA#2@>n~O>Uj8&Sz#O@g{9_yf zGqn)3i#k%YR^{srfMH!PK*^K)*F31=E8;y5T>k^vU7M!BQMZ z9UD|r+-LFk_a8pMhjkb`8GaR!jcHG>f}4eXi^%m=pYqFkWhmD;AG7>e=lGW zoZkXdzSuq5Ot5D8^b6EDg@l66|36(AhEW=Yx#lFh>pZWMcEgYKU)Id5F~>G21!h>GXWpB40#7EHJ*5FEEvVGiE9Wt!|Z z6{UrLoSTSy*sb$`#2-yJ*cd;+QZ&E z<`prrrzXD3`Oe{uQTnJ2Ezo3x-V&3=+1|(EKyS&g@AT2PjV43`V#W;FAP8K6`0c|+Y0$ehlh`0hO1huQ>ce%K^>z|(3@k&>pd1MYg-hXFl zV4i;$hZGm@VABRG`A^3K{NqJ6@G;_^9mq5_v6b&~n4*E8atFoEM@3gXrb^5w-fY!UQ<1{mr=l|54 zSOQzM7r(P{#Mu{@J>c-qmH!V7R&n4&7R3~A93AFZN;{FBCjXsY*dn#7jQKRrBZZKG zK7smYTqB1Wubxfxm|=Ld=hBv~Tj=f$KIVFQ`rh5UukYTKe|ReUVA$gs-g}8xn-QnQ z=`}CyC_A%K+M_ohz`otE=j?fE$%uwD24f6C4m` zcf;}YcieM!LjF9oXJTy>?}n)g8~(%fqj?z&74-OBN-6KAy?0WtVB8H(G*oSV;~=s7 z!FTn!qn5f&*5*Ej|Mg7$2%A9Dg%^Y?xk05UCg&dZA>WxTD~n{O#EswTsL_^B5-p!P z7rbbb>{I?6v-(Gsx#7q9Yk-k#M~_;ohAiyf36`!vXD3O_x;CjbUZXi6?U$T zz&`nR6f!N_+y{gGrgwWjGq37hxKaX@s*v$T$Bb^A!?*pwraZL^a|K8otsBhq_fZa{ zSsehrf<8XKz&>0kT1Wo6V_{}xNM3fs3%gjn{iSQ-tDGq!Z8oLK2FSVLKU{yQ31E1Z zlzd!r7nnM$i9ewJCbUnGdwJsk!b*jAnJf)Hb~$UaHk-#<4%>JmX%ly~HYVA>{?3c(0AMr!=V<=tX#VGDt_Pm~ zM9u#XqGn2mdg`@TI>j27reqy2Duy0W$+CivFbp>3q^C1>=efs!9T?EIi*Bur5qCPx zYZ~rLCTb|Frf(c-qs0nRcH77QX)e58<7f3st2^Hl0likc;OtGbw(d)PAb>7^eEj(F za*>`QLVI$?`3uy54-&59I=P)EGe14&F6u6@%I+?(K5f%+2A9FYtu_j_ZM40Ngtla3 zy;(%m7Z$s1_~2?&2Br9JgifjpkHZ-tI%;*d>9lH`ptgVo7cVDWbvCAj+8rRXiCcIh zqfJ0hBUC1Fpe7Wwiu6j{JMFVfN@mNmRmT#0>{p8Hv%K<@U+`u($wE`CavGH~c!+;Z zDL;XVWB@7x<||@lY}cU5>b!63g!h|}KBoIc4n3a+u9r@-@S7}adbK2b_2Hz;M*FW2 z>Fjz%eWD$U4VU_B;^Bs&zK?=~cjk+eU+dKFjS?rb4mC!ccjWDd_1^WU?}#lhpIUym z)W+QFxcvJ~I@VaBB=mRl`Hdj_%hL&zPdkqk3)uT{f;;B+uQH_eZdR?v%}Q%J#=(Z2aX@cEw%)D!M(X17~^xUryNiWZ5!tA(aCqbSzwQ8_g*RT zc51Y7Ca(S-x!F|JBkY8?TLa9w6zn45A`u!t9)Nb_4#l6w>%`b+Yk?IIE}E!SPuInd zdxX7`U%5^8#!fpCiieTzRv%ycI#n@4VZ;!VSRuu}te)4O^v_KMPUK*;jU{>f{Cy-m z&#Bo-y0z6sK)FlkSvP#dcVjdJF%CdU8{a|v6OvC=|JWV|F#1BdAx^yFtaTrX4kvM^ zW3t3wV%G++_gBDj@#qB_u&Qa5@O-Z|pT4GCK zdL<5HYKv?aUKfpbWDFts%Nqkit$P+YcBp&TaC8^wOuSG<+C&%OlfhCaE^BRYxRF8@ z@LaKDgr%MP5$=7L9yVG$u1ik|9tH+6DU&Z{u#vQVEG*B{Rl%ZDVT%r~p-)fc2u;v* z88SV$MAP@FzV#eMf7qs|R=w;{!{u8b$2ea$Ko$-6b~a&lDs04R!gUtJw#R z_>quX$3<^kO%wt1Gg^Z1OOuhKNYVi~;e0K9VNbr-<*A;+>CM9=jMdBZ9Cn9tleSYK zM0`)G@d4P(klw@Pv(Ug@_?#7MTv7l%^VmWeLC?phTwGxzROR9WXnQz$ifHdeUxmFB zZzm>&yxfmJ=;cCqt#~ARXCM z+Bw)Zt3Wsn==Ff0C++MG4d>Hb6p4nI5^WoTcamcC1kjxLJMUmV;#rLHk|vVG9Ln+v zX>qx-?eNAitV*;RCFTthM?3+(B>7tZ^gQE(GBRllqd=VKBOHO-l^roCu`Y1`^nF3s zzxaUPYLwkxYc;%R?<$jnJlj5kx8W=?`VBz;pIIt(cyh9 zTEt;TJ}g(;P}uLQ5Fu+If&ww-HdWu&80-7Sc1DVo_qv8kBrMZjohVJ7XG2G$o~(Mx zi|S?Fsb09S@vVbLx%%0Ju*}NnUK6hcK9*d+eILxr9Rg;wVCC-h$8BCWf&KeuC|!Go zt{XT$uV)Ool+zG1C>^yaAg;4#Za8i6C9hs~at2F{$4ES$oVovu!68dYu;l{l zG^g5)rryODr`f-jDG3;Bt46fCc3Q3!a=;-)nog=(na*^RR8QwX0e)=*DtT3= zT6LB2J+6T2N^A)X=ucnx{md#0S$!L=u__e zI|HfvdYm@@dH%uQouvg!R1f$-9E|QGH!J%MMeRDqBlsHHTeDnNRvf#ehCaQjni%l+ zQq6arSa6vLW#4#MH^knx-1+VS>(PV0H_2H<`!&MAko^mr{l3WobunaHRll+Kyt;=( zLrsAVF6meR6=jiC$`o7VLAW*(r^%Ky-%uCI%Ep#4lXDGo|6$u^y8|fd3$P$)9I4O? zi}tXN-!)|bcdbtah$1{2nGi*TIW|YcNI8Dk^c|)qVUkb8a~d#nCrlV({%ykCxd%My zGa(6sw?abZ24h-R6con#MD=ugMAXx5yXwJu4}xs^Az@C538GOcAD6)m9ckk{ypB=kS|}er{K3=k-NHbh>tsE-qujJ~Q<> z_rNoo7wP!f`Z~*DM9xQ8p-rc~M&1jKeJt$J=}gcKIn6(OEtV7^aY&5{$Mf;?XDrk> z(-7IboTFbsJhE;XS0WSq*y&skZ{#%o#Z!&~k0$d|3+q2gAW^lnpvp*r75U&u-`i@2 zJP5rUxKL6Y36B|kSh&1U=E{Q;1<88Zvep~7)GzqBB~#pCTX!SOv^4vScL|twm9^ha zf`0tDq(>@L9pg9cTjGa?>mVgdPCXp&eYf;!j78_o#Z4n2!;J|Z00PYaN|K*nV^7Wt|H+<|srlZo#QO3Y zyN9HzvxA819`7H8c6{g_)63auVyKyC5<40@-sblP7mq&MY&-s52yRsJ%x&^>N)$xBMR}) zqY-Is9p4^ls6G;sml`^{(1WNWpX4FUO+~p{f`k|1z~;OdreK^l54dsk{5NXao&-EOl)6b{mQM_v-HbknhX8< zj#z8tWZzP+hHDLtKMDT0zLc#TPE$RCaMF8$sR#&a z&-awH>p`7xjHBDY34O8M3erJ7IE_q0H;RSI;J3sZUdjmU-i0pGm7Btr^~I$Al9AiJ zma>_DNZAC7(_m)_A}J9+$=k_vJ6EqGXph{lx}GWHt%!Ir*2x7o5u!r zzlB+RF{%vs^y>UXaMON13DYYn`{lu4xemSo#j$GT&at9C-%b{O2}=NNcQ||y+Q8>I zcF^Lb0LaZuK{twRNZpeUdG+uNp))h7Qw6?c=D_e@GoRPim`{yA&@~73!S)AbP00=f zy!GcVn>Zxhj4%Xv7MdavHK?g@<%%^3{=5dsVKUIS%62{Os_oUVxkVb(kPBpt67wuI zIU+0G;yIUMA8T+3U{4b9X^o-yv3rDujEiin5totznRUU!#5J64({)LfJg@Raihjcd ze^~PtwgBed5urk>PGf{F%=GH7PulynD@6Cl6{?7zB4RtDYOUVwhVM3HrGKAOgz7)XCeGHX-+MaAf76^^?r`CAcJ$TT_(BWgQwc zT&f>0_l3%47&cMBQB3O;allC(l1Pt->tsw~i##XPmciz{=e=o)MNhm|-(E36vTH#X z`qPxUxiUZJx##RUvM79$wPwTTySSGI{kZ}3uR3{4IAy^#0pN zlEB!3{5}f+5c6xrpLE>LJq+%WjWObRscMI_5INbSA*P`J*bR2~JYp401sKJ{wI?>r zeIPZ5Rn?E8qCzdTLD7tH;wg{d8@OV(KE0y%SYP^Tpp=qK39KsZ)DB z28dB2R+D3x3|~&^1y?g19s{9v<+wZ?m5|`{$#QX^Hz=zOpv4}qt{}xsR+tZOZ%=d4 zAim&r>RX*cU6Sgz=%bJiNiVZDK#RFp@Ck4@?;u|(qOy_APGW{4az`c4( zix!bL7LDC;sr^-PGY3J7t>FDq0itN6p|iW7kKN$c%=w-*=KSae<~*fcy<+oLo$THr zUZdhS3k1p6A+pp=;D1kRm+JBoy>60lSv&%af?!KZ%r~nS$)ZyH9s!Scp>)K?)8j?E z{9{$$EcVl^8{+Bv1v{;i3jgu;A zM;SX~J!`V+o}MT}nINxZ8Mq7Q|GM3Ce61|F^g~(jH|-wLn0x)z&HToCJuh|;nl=XY ze^GfDiGgQ1(1^2cGOv=lF6tftTh-+}CHkXq3xMbCtLI)M(7tZxjpCW)jVE?PG+Kk) z->c0Fp93%zGL+p*Ht#QN_^~ z|9@?G;U56=zk8c7F1#btz$W#|Mnd6V0pIJO?PvdBrn2w-H)g=b{6Af~AA`n!x^(|j z()>RSHPKJrLiFOdH$Um6?u`IcSohXq zx*sty5QAN&RV=RSFTNzwf_;}dLBm5{{yre|nqZqVjGi7LT}zQ%!a}W0-@0Zil6H9w z_Iu%`;wD=a<9!P&JFgK;;IKY#9Z|m%Y{I$n!Q@vS5eM=WXP5k`pZFrwsFdXZZcRcoN$;uLCC4Ro6 z*BvRlw>^*hdIxYn3<;wLWCw&bJ`QkXPqU zv4WxJEo|rE7CHV(*(xdz*e`X5EQd7*la031s7%`xtAlg@X-jGs4= z+sMXjY$$@WD+vJgl$gnM**bn#J@a39Wsy7&s@hKMivwLC)#!5uug;k*Y&*=(o&-kj zAUtM{HyRe5K$Ze}$*SUBO08PD+{*saxhIBnBO-Pf@W_%$eO(~Uc_OFdMh zSn_gFe4M&#s{@*x+C(YS0yD9+z-R=gp-%}0By83;;<%W@m`rM_x{C|$xbN{UC14a) zTACecDHf_K0F#!d9B~&+QtCpMbDYuO&tEnywC!Z-geECRWVb{+ASbpKvEW9t%j~rQ zLE(B1pDeR@BcmfzKMj~pGhD{@dZl0D09;~|4QjZ|^^n-;Rsz%49XX+o9<@$5pYlwY zMUl7J1k9N`&D|MkfkRlZMk%>^n9-*%KwvHwk4>F@tH>^PX$sx55)@h+G)&jA7L`WqwJ9S!Hp75 zgH0D>v*<{K%AopTo-`I0cUk6kn?ztw=qZ){X>0F>k`I6(u>e3co&RlXz18>ELH|qP zVu7~#<0rbd*B-}4r#MAEV$IfBn$_cVKuOg-m5!dP#t(EOZ9?I?h(xfQ!gBOYWWF^< zlm}M<7J(FnFuPUu^ku0Yo>m*HjOK;rn5etoxjfis`EgG0WY1)3G;fAp6Sp>FprV4= zB2_d{(WXDqY>o8PhjXl1S{0n40Vb}^(9GHUjod!krocmM69wt zMhOQ$;gF0IxHR>ut!504sG|?`m>(4?up}l^Na&DX_8eNF;3=2cS6TzUCFuGuv1-3g z(I%@Y?3PD_ox&Jc&!oGu`!-9?$uz68``y9q_q_(7i$+W0157G}g`oO%jFmCRuqZW3 zhVo_@nRoHL1@h?`gO*zlgzY~~gAqCS2duo|ZAg-i-YXRqKJ~bmx)>gn#1#%{FCFQ) zBd?7mNnCLBOY|A2h`Q(8;4a2H6NMbst@%jc@!k6?%yX0snht5`w}R5&vIvS1H^%kr zJn{jGbJ0FY9A8Qnp{xdb5q)W0OQmht0u@OJ!axB$5E=-A=C4uH@?Cq}2&x0MAr<#) z^m0s+MgkuzpSx022{Txba6j3XQ|cU_P0>n|sqDnBQ!S(fhu zrX4o3>KUcd(KC-X=b%%DbZbov!9E~io8{l_(MWhNs<)}grX#~MxUse{?eE$AqqH49 zX`u*$`#SL*b@lH#7_efwD;RCt{QAieSTX8_AXP^&H2&G`cVH+Eruc1DOtWjY%g#WH zM~%sEO@rVUDXRu=95YN}DN$;1tK6@BiI5>ok-DgYqGH-&^>m^F!^BWz&{)L7wu0NW zib})%y1me{z$odNbdvczg&cIMXV{2u4xH?MxfMPr%IA?gh15UW^vqSNbLCVg1^#I0 zg6VScMX^=mZB z)Y6?JWxDe$pf{R3jf^j483;dRFLje0BUU?-aQ0BTZSy+!=g`2~ZAlK~obQCzKuWLe zFm`W%NZk2ae9o*c=GNI>7-Au8WLaYF_m=RCynL4~ZoC}}Ov2SR1>gfrr9%A$tJzQT zBaNi`q|kP~fMwUsku^YhFj0st@T2-)jBN<$G*{OtankcB@+r6S&Vo_{8hV@sH#Dk# zhDQE0fpywFmTPJ2Y8b}<%5^n+apo1VH8=nQx|oEJPV{Q7LJ(Cf^VWx857(g3!!2N( zrw(d00`!980klkdmDkq}rbr@dSx<*S?RsPqRKR?H9i43v0+807uN-<%d@X~b_Nxld z(>^KdxGzUCu-0X0*X%bz?`VPnFBfP073zm~t}KihgTn6G+=zC(98W`{eWHA2rMJ0W0Y43UGzge52i z^ly#5yZm17%2;Ss5V6MrEomwMXMD=G7wg^7O^gzrQ?zg!7zWP>`sPg_YG}=yw zgONUs(WP?;jfBk-j?=H*2)MMx#l>Lr%|)v#&~aQA60`=j1k29~blr(Kt$B@}c@>^i z^Q4yPD$z6(WVoHm)zq)ota@4T#YM^=eSVmDYsPl zI+~8|Zw=P>XbZ64$dt5I+pU3w_zu-l7}LT`8at!t#y?o8L*vuu*Zm1~b9+8UoeU;* zIFPzC_47vYWeCKKDKn4U?QPP;`QPk%i^qKI+4pw#x{lZ#l9^<45D*f2YmhsC0x9i) z>c}R`a1GaR{pGa(5GwqG0kU2EuDD1po7$xgBQ-V8}+znKr){T>TE@oZzbaS*~ z5#Fb%$E_CgOI_y;fH;a_Z9AP5*Fk3OuQ7KD_)&+z=-p+a4IgMyI#qprfW-u?1XtUm_O+EO_`lUln>1^+l6Yd*@O@Dccqx>}NmFT|5 z*ydYlB>dsmY1K{nUccMnlJ%`Q|MtgQfp%Bieq`{d!FqJy+4f&3LH9Bi4gB(TUkwJ; zZT_gSeCK+fR#oVi880*T*^OcjMf|-DxAF%D;oJMZXg~vH3WVdO9qWY5qct?DHNmZ& zdX3j8{q+K;*lUpKQaUbRoVU)7=m9V_)m5fGGc%J>s5+JG9SXE`S~iv98*=th1sXTW z1k@A&t?NsktZ8u*ul)=Ed|*RU55_6DtT$sj_XW7FM!w)lQH?HI{t#{} zTcnqhx?pE8I$K|V`uOomn>;WrRW(XNZcg@krgoazNpIuIIw?;lgmLsRO~wfbtgo>E z^WK+P9X%i7xx@;DWTH3cZiiwg4vBbZ=t0ip%ns$aTpr&_D~HI_==glFObtnH^!e~% z&p!42p&RQ1c2kmQ~mi^^@ZhYHe`vHoxYAO3Y`+IG3>rdB%{x{dd zQ>f0vjiYqhsD1zWb8Onu zN^>vrdmU&gSZCg~8LMYP4+))p=L`hiA)I#UX{GM7)n5X$p@*f_qKj;`TPh!5N+H^W zsXlz|cMa)d;r>fN93o6=7~|C{4(mlro`Cx3*T3*fEY`kLTB2-59B)woBps&zgkt;X zQ}gb(&hC}<$bnl5J0}Xdw2?PTyhD|=%`M5x1R(V@0YF}>2a*Seh7ITMiwo`i5x;Tc|KKW!QE17#j8MvdanM4nm&rv?f3eW0D1r~IJF#F4R*`1B zyWSgciOvfFgk!j#$_c5(^g_G7r=#U7DRN~mtg*Qn?sjt>v49+llDDf17f2Buz6Jp$ zjj&(EEh1j>bc-h`xTL0n4h>hTck4#kSv(U_<@A%smgbYjIc=M-%Tja2mDqohW!+yh zXZ$Z*u5~ZM#lrut7a@QpRutO(lN$l3%J^es_3(yO+-h;zku~ZfktcbvM_2r0_0vCo zyo$VFelHazA=%=I+IVf!b^`581`ss>tj9^5!Kv zQs*p6P=Dbqh=RMt+?V~?8Ji-`TfjHXw&nCo{@9PXp9ApDp-tbc+y5vX#rHdsRQsSl zrSh7ZY!}uf&5^5yrC1BnMXbT0#cK`(p^hxlOThi_^+;BCvqlW7OARAk%UjX#iQAL4 z?@HR3U+~AJUEaFWI)jCRU(o4mQ;(ln0elU-s4r%$z@{ZN59o$r)i#!YE{Ol z?n=csFkPVi4>FU)66-GXIGn-DZkUVE!gz4L92X}l`=JI91}9Zx#i}K#E(D8$8!fFD zdv*+58G8}!&`JRxs7BhAj#$faEndVVBD?HLPtlY{R%ysHn`gI-7eD~(m?*Q?wr`XbDUUM_AT4fyj$L0#+$mVuq> ze2;MMGxuE@iu9@hO{jJVYdxac*gajo_NxJ!;$2|hr>gkmu+of|+Qyr)CS`K}=Vyv0 zr2oI*8hCDJ(z4?oTE@mH(V14(d^vVve(4e9;rP-<)RBA&oUBwJBc-|KX{l4d*mDR7 z?$TfDW*cOI>3Kc=L%%|WF*JtX`CaMD=yeR#ApW@~zIkl^#$xe~wtTC(j{B;uE=D7i zC@*}M3{DykpLxB2?}_|M21d&H20czbQ=KcSQRj?JYsf6!Cb`pgtUN|n3FT!M$?+V| zzDoD9@JEYkrtQiq7wN=aSLIf(KG{Qg#3z6WuA=uIIcHXzyt*{&ncz}Hjzpk3nnx6^ zYZ|e&CMp{>uwfcBM`7&uCuMwa!y~&_+*Nx>xh_&L)M@0?5ZSt;a7uE%%K{p=2TU8k z<^^lya$s7mOCB>QU#vQB^8BQVxv-oBE*TM{YTAN(|y4LnwT2?p)@Ov*co4iTZv+{RA~LNxlK3@m?8 zny@;3pxUw{MwHHO1SMpw_X25zh!K5NVA&?F5#L*Jp6msu1d!^`>?SHcE9-3OTf7^vt+@>#=$3Lmn@&6uu8A;tO|fNm zy4{p53iX40c(lJGkV$s*CimgSjgu(;@R`!adUU9hNo>KZ_nW<0_#Bp<14TPNC4K}(bDaFZy{tifEe4^!K5U{O#P&SDo_~@EQK`%x8@c8@MHsb7J zEzObXI?>+xdJ__v6DoyMp6K**P|N5*x=$<}YdU>OMAy9+!i_1qTeitnG#&42-C_S4 zD5vT1RU0FfV=3*mc>VEOY%8X}o_qxNDam!B`^1bvLp^5)TY&X&;#~!l?&!>&v=}I^ z`!!e@yJdXuk=vk!Kh-0m-Do7f86zYlbV&4OlrMFv*YD(nPe8x@Z(!pPzJ)0w8}c?$ zp?qd_dE(dz=UqbU%G?2)26yVJ*Yz8(8!JG|bt-d$pruoNnhcx)ht9h_!&qY;YlWk z;s-T_y+$t)$1n{Dhpq*sETbjYXCn6LJxuP@KRhHPZtLejmeK%&mC`JPwt+dF1FAh- z5(Jd$>hqzwp~7e>*Fe&h8F?lORfSgP*;MoKYL|xR9V=5IVeG;MqTkjMRXKjV{#(*$ z=b;7oCD+rz&Om04-p(Byk=b33H#N^S9TTvQKJ%7HUF7Zn%|%$AaUJ}4vSTil?heVU;*RlXXRraBP+XaV`NwFcaCN4A2wlV7PPL?pMb&2;0gG z3-Is&G==4hJG(`b??VZ9+o6y?GRm8DWCVTZutv6#xO8+nqh@!x86uF0eE?HtXX^vD zQzdXm0lvJnAMC^_8}pNmTgfXD+K>N9Y`5{#7Xvm+Zji7}!GZBYA$>6C7c07F=e~0b zOXDiy9yd5?MjujJS!|CQU$%4= zuPR}^7Tj^C3)^faP&3w}uYKWKDY@=_!&#DJ6FJW*$%X7P?sB7ei+6fJGDpN@Qs-GudNO=hM9q#!J%=K?8>dRJ{3F)_&BiUiWYU>MAzNKz9ITP6>%D!8x z@^05=3JEend`B8S+n@~7W>`!x{&e)nx5a)&KmaD)RT6UG3FSt`Ue#9;l8%jsZ@W;u z-yzzn$Di4D?VDIV^!by%gBKN61$(~C4_vGRHHUcJfzy!89z|;0?5v;IN84`0R0! z&r2CdjU6UnnsYd#ril$g2G7cjLj`C%3EBpw^n{vo4375)J7G7O_Qv z+(ijdmbb=MPbw}(wSgPGcvP6i zx7qzbN!1c=EavkhMR2Ph8By)m9-<@>!)5S>A5_9mzp$O|K~`_6;&kX%3sT=18Of)o zD@RYl4D%btiYAg&GIr*mZav_G;C48VG(;+j=UTM2B-IpvS+tNWU4<0o7gt3*9Paf8 z?c#9qoyv6nLWz|u)B^5IVYlAfu*H506SAP=u$AVy?IrPUl7tCzfla#VL@Mv~H+NA>3z9^?VNYPG zl`Tna|J$sK0?ak(fjC)GXswCU<~Qg^+D&U(@TB-M>_7e5X-Gg1tf@qbx;m^bSxkK{ zkxUt`?I?3t$ks@RDbkM?;;r)x?)=kT22+l}r`qor$r=I>w53`VJ^IBTs%@>jafV}9 zBJGB3`+h?J-;b`Cp6n8A$z<~jN-{My)rS3!|3B=#XHZmYw>7GW3X)?ZIp-XkjL?8& z0VM}X0+MqEMM9HBa#lbA$vJ}r0ZA=6(-IqKph-<`_}abq`_|d})IHx@_x`(es@9KQ z@GPoXPnctlIp&=HvW$#6H~^lh!XLM7_6sKK=&}nTzff}RtY0{vZT`qIo?uNFLn|gG zo7E6zs8zxgZbK8cHBHW&=Ybz@PTmdRCZcWQll{t4FaMgE z?IOrktK`EoziisCvIr`7hW`o;{^`iQfAE_?|JIt$`!53B>i;SZ`Xk&Bg2sjv0oP4L z%RN_{SqM^}o#%rD;}{RqQ$u%)K-W;B{FfeVv@*P12yqWpcL+dB z8T*qN6dsWA7#TPji5xC7=b{TE=@-Rn?@Kk;AHvK}t`Lz)!+aKs>W;A;H?QMlP>zb- zujBtlyZ^%%d;9*6N{9*nc_qX@sB84B{-#eaF0E~bDazS$#Qa>mmbZdB^6D^qTw2iO z&5?oQI5uZHsO;6%)epD@4r$(B_3ER?xhQUS-{iJz?nGw_dZo%3R0*rxZkr}@>1)EA zwpox%^LP>|Msl+454%zS)3?5>jP@MC*CPD?ae(qS#k22%78$)}iXiy8k!Je!ca~<^ zoXh7+`lUu6Ho&|$pDH>F72RFi|77LTsN-6Wj5Ld|h;-&v71#8{ai0 zv~0%c@~}T{X9_0%UDD;L*7n+iou%WFdX??@^__(VKlza?>78~O6guHtf8kX61l@g( zc_vxnZ_$AN;w$~0k#7L>qUzu;3Ss*LBl}$@Tq9`z?^>ka;ma#J+}}lzq{WIGSGB%7 zoXkdDo=lKM(Y`_}XJ2Dw(z z&0ltd()q&zI#8LU4*q)t<<48--xa}{qy7s*^KF0V08ce`{y;^^Ye3&gvOkiKdWgS; zu7Pp)J-S>Nd}`YT=-BVY^X1JI-94|( zUx|_M?N1N9+R?I1r6waKGro3V z?dt04WdrA8&+^=zF>SO*fi9Bte!B8DFW77F1532zjOmIrpwVKn1gMcIrK^@2NSfYr zK?_p0o&2Ea(Jt509C@|reP($4KgPa!lhO1k4vR~(`Jd70BYxdB)2am7?@?}lr_kko zwf(MkV&eE`@z8mWcRUo!)e34$!2vJWKA?QqCB^TH;i6X6NKQVJJeNV~4q(_4~LZ-^ISeiXY%UtmOsd+FeK)-4H%WKQSV4tZ-48fzeiURhc7Qh{8oo2^;13ABt)k{oWb}KshquKe@olYIy3Ev z0o>&?G;M4@U+Pikcu}^RHn?D7a5`kTEhx=C}reK!rXzRP(`m^^M5Wwu`TBXM3;bcHAlvEJHHWVb~Mv4`BVV zv|ro&j<$XSzN>hx?aI#h2bbKxFt1G`RcFlM_m}RmWxq3R^@dxq9o(SHWqJPk))iJX z?s<)l)VyBU#71WepewI&+Nx{+s}{i0N1339Z2k`Zna-o%|KYp;?e?1=8dJy;)WsYU z|D`8r{!jD-4~+R2N6YvsTAN%Dyy!lmfR8e0vD@*36LT`E<1?!IiGae*5jeUI`1^&k z{iQ3k_$P09i8UbPjAuJMhV*$yo8NCCpF=0ffYv$dxiz1vf{nFxnEU4NHZmn*!Ik5G zGS)BA!i^@fKAaGezrJw>I%DjANGJbYJ^24qoaukABK-dx_Py<2_WP!$Z5nd@mGsFo zZ8@U*pz2UE!hpE;{^fuKR!S1ZxFzQ4S*Fuj%j;5s(`6jaQ#1Q~`cNHA(u0tWcQA36 z^rgmDq1^!Z6^*RmmG`Ut#f#A#;|evMh&n z8fAOZ5E0(}+hxa#8JN9wmUZT<-|fnl1<~t12n2GH{cX%Y9{Km}fV^Dy0ryGf&t@5m z+%u40y>s>Su{n=sOFS0%&EGCz0EUd_$<;_e8<#~>(uggWg|7^CO7!2x{Ns^--&!-! zTlkXuYiyb1@k0Xg+l(3w;1`wTY^KVXAi2L?nK*ap*e+3<(#t9Qi$6ANQ~0Im;#C#@ zHs&9X{QI_s@IAj2PCKh-g;qLDTHCDLxD0u%ACpwU^!GPQk1-PdCV>PMd`yc_rP#MV zo>QgBB~bp%_|IW~n~#4@k0I7mirjEB5{VhM;p1HP%4wcy|C`e9Dlv6_K&CjynV}!$&3q;uf)!QlWh8FCF{e<9i(rkRtw?( zey4wZkn>>pwFc^hw)M9XM@Y^EHV{X^dbCUaIqdIq`LAcmF^D5nT>H2NQvlg2o%cP7$ngnm+r zzm*9*MfUn%ab75Nsyvt1fccHr|xpT zAoBeH2Wx}I8OD_ZyfnNC?$D*18EJhY>R$Y`y=EoPeeFhE=W(J!eWNx7IqJ7Gw|V+K z0BA5>(6sCZ(bkez7r$$1O7}zaAi(H3G;1TrWKB7*WpB`8P7#1*3q8~G^_gtMTh=Hg z_lOLZmQX^NMqU_&)7cK=7Ifqgo1I&$?;{2|m&hNX_KZA6kZKKIu7OO8mWZoZTp~^% zgL31o#o%Rj?W0>nTI6Q#nYsI7olJ^Or_)%G!&bx3K%}6|Zq|>uec(@%<%#C%y0a+b-&m3)LnkSqE3M7r5A@|}c)75ZlO++PMB$8>pCKvIgCC9T zOZZK>ECcK&IJsT?Zcnf1y8EqI zCqby^V901t0OqBRPp7EV;^EFu`N)w(M3$QQ%lK~&N>Sqprel3RG@A}I^M<#pa24GG z88T!<*#+_J%Vx!%nwVj_yS_o^O$SFXt1Q0Q*_{4<65xRB2gNDKVe6ViQtSWda0RMunYV*(E1jQZj=-M z1#KJj!dGmaJ6|(nQk@Jdqu+eTqteXxC+8yp%JLGRrIcB3S!En$yZB#i91EFhUXMB# zd@tBziWX7+=faV88XlLyFqa8IPs5MpQ50FDy;7_Gpi`LiiRQ}>P0a>;SroECtuOM~ z&Do4E><7S)ybmsi!Y(wxsdbS&^XGAVftlA56VQIA220W??hGu1C>Q2)OYmUDyzR)F zZ%c6j5T@~rqM77IyeSv(!Na{;J^Du`i%MGQ#5V^B{Z;c9C=U-`Eg~t}#_ee2d8deb zVAB9&&FVpNU1z#gzx(Fw+aO_odEj2I*oVmH8!n(ZVEVJlHAn4=^44KSi`)7%{Nsjf z7UzxYL)P53gB2et$C;*E(@P}y5@WpwPr0Nt?}mMK6)N(X2Qkk&#P_N+>4aa~1$DNU zk)Whp3RKS(ZRfCQ-aP)GjA~f5#EG~!y=Q|;7e)F8IwI(T#`w71Ux~zBXV)QBz0$H@ zjo!a9KMP!fYHTYsBaHq1{1vx~&FzypUJJQBlIYk;0qPD3W67z>iAVnJlUpE<%+P@$^ z`J4X`usW4L>LGf|^q})wua!bX5Q*YlDUZk(1$G7Z5dP(kK=pHz8s!j@jrv&DcrLQM z)S#6|TJ)A1D^!FGK}t<0hXjN1*#;{%LODtxAI4U?kVr*fWz8E-o;7K1`8{#T3UbAG zsg>IZ%!y)QDup*K{t4kd-xJ#D9)z5Fja``yFz)BH9`7W+IW#2UVf=X8g-K4M?pR-? zcpmhfu|kB@ux#tTj(3CTp(qzd)9f)AFAWEDHOz<$6LjLP;UwxIFeuW0$=K42)uUDp zCA=Z2_oLO-ZV>&n|ASS+(b2*ey>`}JYL-hAButkoGGW_WM-BC(r`W@=DeR8&$OCCV&^3dFnwPhG!k!ANEU7IC?q;cwi?;rnHql=-qJ>j&Nlmt5*DVNn`H-HvMJ=`g`0z|rVRkmp)?7J z4l&;qif;$LNVcpW2Q+ymh{&rs3=v%EpR%`ncKrcQA3cUp-BcUj+Npn={YdVVa96r# zdK1*$8kZ!7($B7>D8050D!zi!mU!2b(>ZGTy>grj`Jp-X$OCQ8F;WgOJGsWDk3u-Q zU4!2(xFPpfCIm=&@E|iTIKe8-by^$OtlCD=7j!8$Wl15=!e#G;>u2j*}=2u_nyamh%S!CE|2j+(1EKi8y_z7QsHYSfP+s? zBF)1~xyOsSLoh-F@2+u*Ci+HTyUEyLX!k@lX|5x=@-F#_SvEuZHG~u~bzqqv-D9y? z9s?wi3s=wvO2nIbd3}3kc-PDDrD3V0vSZgx-#mu89HSCR5P!ZspdDOJ? zBn-c2^B~v1u0zs{h7y~m*@dw-w|?zw;>Sbx7~YsSK>L_X;H7-boAWV!a)IlRI1+(C zcnW|ZplnR2_c(Xhb{>l5$ty@ixjJh6Foaz37P%&BFxtBb=h2yo7W|N$Vjl*`uXzHAqouy~=zWTaM@^$-w3b45L-aqAzveOZ@ z#Y#5r%&8d}d~4SND!zeIG3^L`KkjvVE#RP^cjc?zwEBZ!=k&#K58lHv66_v=rjBhz z+{(J(cXW{L==KL7P0K$}5wv%SVyqKVmGUlsV5$)>KJ4RIe6OseF->*P>!tkaE+}1K zYenapdogW zO<4Fatt`24`8sJ>#WHy*iwf}|-%Zc`AcfFX#(0pL(_jz$B0;si$cAyol~?DMBc3<_qE~tO*yB-OqH6a6a|U69p#8m4yf)B>~y(UGZ6k8wu zzgU&FtI^opz1UcKL>U5)&7=4q5|OXAjIuIcO@we~dNJ|dYJ_zeMVa7cn|{a|JoXpYHTqtW)KF>SY;S+=ladVZ z`Pwv%nUs*_TEoDk$~mS(=X(|7dK{A{>UmB*2c7LcDBIw{|Ga3cw|{u(aM(P4S#Gjo zebnN2owFf5axBetW;WO?nlBzU45E}dN=@6DDghzcKG0eY2$e6Z8|k9AHMF#++q4o- z?N#+JDDUVeZ1J7VG>p|BO*xGo(4X}R3$9}Y2*pz3-ZPPtxdKU&9|y^|spxZ@eMIVv z#0?-IS<1t_#~d@B@G%){2gpMvwDW+GfP_QBd1G4N5T1Ts@37&~jHI$O%O719w>}k& zKcKjKWDuhha8mw`>i!dv@Rg~r&^z0j8hmqutZB^o6vwngU75BYPE82+YKaL;bE)&T z7MPyjz+@aE=p1cAnaXZ5F4tn`a*b14GI~u00&^6ngLm7tk>j^;7k!*1fHPoJ@UHWs zc;WIB@!LeCwE2vl)yvQM0H+r*YIJ$h`E@hfK!KIv_0vcVeJqr}CvHe%3ZIm`M7-7v zU6uFxwL&2(f?Lh}s)|Z)|P{Yc!jcc7|Kj{?G2-{}w zW&|a5vl%Z+OszQk=*8}1(Fp#wMJRLhMu_IVhlOvmRZg9hw*Iv>L3uDldT(H^v$U{m z}sK=CE%C%I6 zT>1olkgSN}?VsM76QHjB@#ubmGQ-o^iPR!zheVu^>_go;>;3Z|6WsHju%ETMt?Yu^ zY2-AB<7yJ7t}qx|?nB14y(S}{82^FiFR_xoXb_}H-#ihHTAawQ!>byY0(XJ4PcjsL zwvOcM5MxU_FLBE=t`(<~5;*8Nq9ism-mq*h0+vrRw71iP-|en#0< z?!lb8@)AEaDkYOAJR4%3mS~rVB;!s16J5|*;LU`thkh2s14)yG^;B#o~Sc9 z(qQ%6ye<^E@5~*wY_)QcUv{`iD_}uHG6$vur$Srdp<~B#KZUKgW?k2C@M0Q>o}zjf z;aa(;1=zD@@%huNh@8&fPC?nU)5_283XyNi1i4qgAbm=Sm}~t6^@6uiQG7UaMruCz zZRW}7t_g0Z%dynALB2t=O$Y0d=BZK{rcwNDF)7+RQO0m6XvG zX7+wVYZ+ayV^Ug_karP8t?s=vU}?gmQ6eKJ=wIPMmU5uMo4wYcKk7JkxNbl);p_D^EV<*(M%LNR*pG zKi}}0XvzYR)pT%0_a#z~by31@$%fm~YpBKg%MDN{`oZ;&bVDybwXd}%Ei-x4Pbc-m zf(wbncFuXOxM@=w6-R#9)$GyP*yQ+ohD|lMo|+3TzDlf4Lc}Q7eO{tWHYmq9T>Ka` z6T5r?&G;pJF`U%xIuBls@u~7JFlCnbSfLC)3*TUJ_N%ime3(|QXC$@z>MVl&dVeOO zpO2*8B{@>~xVSkP{+7I1)?V4M$>gNX)vPP(GxdUX`1kI+aBk_WCW!>w8!EMc4HgN| zMx+#j%vwhzmBY?v(2N(YiFxvxW!sl5Fk<6PBkfj z$f7?`iP7m$f@o?NzJhhch(F|U+E|D?ThA4Xco=r(Rv@+Sd{MaX+Ea2@#<0mtI9Kv) zk5)0QsN++@(I;$pdz&_70vx*K?p-Op!708?0b)RtmvchK__ zy@Q#3-<1+Af4l$piTs~lNl;zKn}&%U?FH2!jrJU2mzERgx^1@HZR8QO!fU8F{>$=HDn4J- z&PDYKB?+eXZXOwkeJ6!&~g~9CDGSUJG@H z7E78}&+MT|!t)E9l#{iKLP&hnvJ0yg9#el1U5f0bpjg|1if}yGJPER%<#X*BQ>dO3 zG>WRl>7g175FBy8t?+D3y#uo9`U?o5OqF1qrrqO1ee zu3~#65!HGkq46IAScOEYk}ax z%6N^&>$R;XImBXvD_sq*aZq+*%Us!0ZPedZ#JD`Mm-bNkkRhR8Qb%$@m){nc;PFV| z*>T%lD+HI{Z^mWdNd{DzJuenu_ik(A`V6^J0t+xoaa~(%;=gt`Ua?|>yOcZ5Y|`3s z`63Vj9@di~NC%e2x{G^ddA-udx7~f$tsJ#|RArp%!P#OgXy)9ChLA^{P} zm8xZ*pv=W-_%&145)CdiNO%*zO~hsHS$WpdJtx7eCL5|+L?_9 zyvM7tgRYc&?6x5OS2LPG%PJoIb@sE+P1z9byMHO^RQx0QL>Y+=o5uA>pOyX@ZECiy zlM6+YOBjuxEB_qvUxQYh%xHQ^R{CE7r{Dhjar2d7Nw$reEz{^L9TtwhP4I>~@Ce3p z1xgydj!XA#w;_*XwL6z|MjtGWmSqHvAHLoc(kInL8Wy(a!7K}O2@lGgLs>h{usO8g z1(mE}6F2%HA0nel3&ad%GQXXzrB7;*(47Z^jMPsU3|tkM&x<6@;(FE8aT(*E$o+bC z9H{Nt1J}6T+SD#Uxv)BC*eP(oW$V@wkRe8Z!8aWH$*-~ZBQ8k?X}i@*=N5g#2d%~rAgQ*aE{@Jp z6*xTk;%8f@Z=xFZ8={V6Gr!Ra=#Gyz-|jsRK3pT@b#Y2MYFy|=N{hZV88E4PZr2$C z!GtH0I|e_2hkyL>NNXnnGvt7+|4_iq_maBhkt&Mt| zv=Kkv)$6ClrK?_-YmLFy3ver~Yuh*+P8B73Nc4`ViT;`I?U0LtG0B^g-PzwSC@}Zz zr{vf=n%Z()c!tv3TGRAS@-y~}3coHbu6R0Ky1U+DlIH=Qq=ASb`#%=R#)5dL1J;#>P~06Gm4-^D zieb8dXN)Gl+PmGp!wq*z>KxYOJQCV_+}0?u<}3X+*A99g)+!&!PRtjve<)JKRjwiv z+mY34{S|B`0()c1x+Z9IK{O< zT8On9q>0IHQY=Jbv2eUUMTIvcFLXNAx-WQSPSV}Pa)e+F-uPZ|A;Sif zKQomLuK?Hj#D5$O2JQLmaVH+%*XvvD_n>7Iqvl;zYL*zIK3PTXx3cM&|Ewkko4M> zap18*jVi9HDCdw_403d@lM5r4-Bub&xwTqjA`v=(1Q0 z{7kGzJ>S+)cZmz>(3Qh(oOD+y@&E9!AL%^&b~!;UE+B1Q-~#rEnB&zi4%I zit`J4Kc#dcz^B9F5V+Jo__XxdnbEWoKsZ)zxyR^4V)Om#vhA1+ikf${N?EVJcB%L- zX;3C}8sI{@&|ji>NZ9k?pYZ;?T>GKMy#KFIB{lUQV-A@;k4M z>$C%W&_mlM&n7L<(g`+e2JnKW)W@$6Ot^NI64+0qd1OLFC2IXWH%I|SzL00rCTHxi zbmK51eP%h++}HaNh-)Kqbk&%nmsNhNkfjp5rOb#M!LzEn!x4K!gKBGHi0I$G`$;U{ ztFJM(0D$0EyQ6C4W6+s+%IzGB2U+q%`aA7i5Og|>&g&LHxdO8{VDH89ixDZWq3CTOJ*LhB&T)oeWKxp{N7!hj>#;G9)CAbG1q#D>#4Y9Th?lk$p?s z70|CL;s(oTyL36Z&kJ?Hvyy^~QRQ;sa`2fUe=(aqE!HrW%i7ZH4<>Mg^26>A@@V(C zR0rRQw^TWPA+ZXzEdfwoYJ|EEQ1rvuviau?>F@Oss1(Q)nH2^5DBKBYLIsEQyr+V| z6K?%JYl7nM^*&%lH-7UC)<;R<4_lQfw6xA~MgsA58!k}M4FwyE9y8(Jow${(YyVN+e*gws+lACAy6Ct!)BjWfFN1rcHO! zz)#SyDkUrl-+MdvUcSl~%Wl_rCN03;v}`6t>p`NAZh;Cztn!sq8DwU ziW`if68V$Xn0kzT{+=7`7FUr`TA25+2#Hu3jhIvmsE8`@4MXOh3cod=zUx-gc2V8I zqfDW>-kihT!i`|J!LT4rsz{dYeYZZu!+{GiTCR~10%);@zgB z0~do4K~tHs*E^!-V}7Q=5|a8I$bU@ZX+P$A5L-2n0+TuSCaoShBb!gX%yoC7_94uC z7Ed^#qiLvpr&`6Fs}-xyMEG##;WEBUKP!|dLEr+Sig!rHE@eL12&&waZe6+1>MlUU ze`Tv%a-MQ#9oW~NVU;==Egrze_@qY{W8KWAtrS9D#PCR&npP}i6Y#iJUwtvBPS#~i zlDN(MRXiUb^E@UX!dN?D2Pd0)U+6ATD4-(h8G-g)2xj6#OM*sjCrSuOB-1!cJ~L9t z2_tcJE#e!-Th*H7Ulhyz$Li?`EICGYFuPj9ch4fJZS(shVv9coam#N;u==lN5amc; zlu5;vL8uddVH=-B={=FF@>=)>k>1{F6>2bN8SH5$=pYf#It)}5xCC_Gjyy_C-#u$= zm&!_C3{P`abX64g&E?b95ujeUah_rRz|qaZ*pnA{2G^c@6(Y$+0?WKZTrt_+*m2_L z3*C7&Z@#0L|EyvG4q(_>CO=Gd(ALuwsFv<_*`e)S1~-kzTBRWbxk5!z={RMOmDF%5 zo4DeylL#ya1Ch4@t_w?HVpy4k%|y~m1y3$-)*G@fdHkHO5%JTsd5vnF#V-2AW}_c` zb{gMq#>$y$W(B2*kX3wD(eLvA;}a(tS)wCT7Zn zc$6*9uv0tL8SMZ+E%P~u<10LMAp|IU)rl8t^R8}=o#7zyh)C8jpN2ddvm39O903V! zJgsh z^VklE26;x_^_VitYrnQ&C7>qFoqL#9poeGI49{2}iEHm%WKYYkOJR0XE07*MEK_&3 zGhROK*9g0*!L|lek-51_6-s*+(dpfpTfQs@KAd1fI(%_^QZ>q{dvY(nN+PwK3c-TE z05SG-9i%}}A;?k5&1W6?f~?oSwiM zI;GYFefXK+=C>!JBNp#P?GaR{KxdtUMdgost0rJnWE zd@YDskX=U^c?5paEQs$)9FDW?xSem$ujYViH}*m|$~rqQyz2h4Pn2k`+l3Os;K3XC zhYoc3F_*www0(+dEb000_DS^*heqme_G$g=I!K4o6!4n=)4QhkQJqZ!B{DzrZInWB zND6WtFun>isq&D3DMAx&g&5ZObj$5+3qyQRcR?y@<`q(Y55}0IM1S-G6Hh)U_10)O z5mUN}CYB5d0HDl@FMkMwEeCqH9+O{)a>%IC!b9j@s@b@p8*H(6Cq>ZrnoiAhVcnVheGplo88P!B3AfOrl;l;{x`F@4?jW;bBF+*+RN-&i-;*m zASvN57QDk=j)gK(MQ=5dhydYDd1QX0q`f>IdVOmQN?5Kmxke(0SaO=CR5wRR85v9# z6MH5`$G;G{0p3pDwo7nFtB;DP)yrmVOTD#jb8qQoL&=2R??x6yZSXTg|vkw(A@8y6zOUR|K=x=f&1iAxw>+3Ep>u!*{;m@u;$o$-B{7;l8iq z_7ZCIgFaHkELxTWN4)3aC1)X!0?a#KJ}c8qByC$WiTs%CBU5flA19mcOBmUqU(Vah zAnaUL_O^$}=22IWLd3^RyGfwdSRc7ypB%apa?K0M)s4FM{B4!ube8MY?^7lK^mqyU z1VTrHt>j`mhI-mh-usk_6D*x=@~zQ?6^B4eD#c$m#Pz-dW1-!#wB0dTN*@}l#3US~ zO@-{soMM^UB4y07AweZyUg%4t7%@*l@G8#F%4K&~Av zZc3Xqz3Qybw+;A-$4BZFJhDc+Xz}(CXN@8jj5@)ovKnswrk{G~cFSK=_OBEBoK4?7&z_M@=Q5L(j zOizol9a-5K1U*08dV_J>q>DF70@P-XYX}#h_mboSFzY+#2lx0z2(ZOVJNvnMSOA?E zg47su`uE!M^3;rOS%OXGZGEuh*VP`!8c*LCOO@9_PL$CCnewOEkf1RiZ-pwzSFAco z6&Rsx-<9um9Le2IGE$*S>B-ONL$+P^`JfAN$K}Hdi~G#;Cr#SCr>F}bZqQQMc#n|l zFtrmtN7YGQqZ%B-m7Ln~ry9wQ`ZxT?KU=Ka`9o+8%N@hOMhm)e z7E?RO|C=>XX)7>O?{ACPH*mqtXnSfq{JRPG>; zxL#1~k5NapsfV=-E7bXwU1je)M1bFEV=;+-KlMQM+o<2T|g%Zil0|gop+b!3y_#y}qainv;vd0(X$h&YO;J@1$=PgyS+SkFhQ?3a-SkX2vYsCwEGJ zI%qrJs`31$(62RVvu>^1b;lWWPv!2XqHy+Hu;-74p$W}k1gzSrSfdogy|T@e37pFI zC-i09F(%|l2@M^ zJzpCUwgR-nYZ&NqD{!K%Lu4ktTYf7hW&RvA=-2PuVky^~ld31RB%gecG38(A#7=RnmWn6qbS65NtcAOWudHWX zr&%UP0o4Ez{W%!zIlTfHFssIi22&Ppv@&*VhYk3u*$Qhr=Ow_Q@@E!=1@6oiddR2VWSP%20_nLOHn8_&;eM4$hRuvSV%oP!r9Bs~@ngHr z@#y#IH|-e(W#F*Gt8DU!o}z3hb6feQbeAD#$M`!~o-p5Ry~`w^qZfnz7yDLUJ3}nY zuezIHG~2locs)!OUo9cabLVcqp5U|_Oep)(P=!BE(mtS7At>$`+}V{}3lEN`7)9to zf}|vN&U6|9?tv`rg)fe-sO+>wM~;~H z%7xPT)_r(~SfP24j$Be&k-M)s@+aKkjpxYoY(=0-*ceH}7Rl$gzQ=sGK}^vUxot}G z(2b5FS{oVx-Z5e>fduei;dNqT8)4t-%J&k3B>Al2r$)@w9V<1p*t5rItuB#T?{QQQk@kU~3xHpn zxSLI{Tn>cHA|uDa!@gOwKYZN9YZ*Mxl?$-k7Pc)?&GS3*$4P&anIcb^O>gp)~ zRYO3J_g;-DB?v3iE<0S}OU`;3e7Xz6$S(J_1uu_!6ymX48#Y&WV-qnZmOZ1pL3Q~& zigq49djC1NAofs(p*!>*zA;cF`Ss_`9QaR2L0tuWrgO%ddiVz)K_5MenWD zQ(NYk`j3Wvy;Ybfd9~d-uf0`)`?8gj3w!vMMRSAnAf$%SAT82Iq1%zg|a9ki44N!EX7CtHY)o1B(k6fCojH)%{iXV zxP(8OEX$y|gj9k4$2m?G3HtB~TbV2c^Zrog=!@nDe_M*i?n%nGs#LqNi^r0}$@d_c zg7%N+zM7xzp999O08@nl5ot9?8F}zgwwr(lTsb+_D9*&$6Dlf)CW9>7p$_!XB`?ju zX?e*cYQoz>Pz1}r07B%g+%F}8o}CU(VwyH8$CrB+G34KQ_5bce6tmGf^}Wv&m3u6LRf1i(a18Ho97SKmn%`^v7E zdGL0O1k-U@r^I@liae|s8D_|6=z}KIoxp0m!-Ivr!6{S;vk>SBz;KGs{^Sgc^k5VK zRl=wEe))0Z=BBT{4K2ep-g3!);bxQ)^@j|1_%RxrK^|AU(3<)y)h=%GKTNgDD&@~E zHp4#MD zIjXxbKIb95pWx7(9tXivB#b2$1h|vz$&|-l`rYiCz9LEHBhC0IWs{Y1ue-d7UkY-R zx{;BXprKB{&>WFJ-KH zE{PFa2VOOMCHq*2ffx#C`Jzm!1a#fq>jV>`<_+iTRC|fTk5mHISFstjNfIyfHk**@ z*zv}P!bCqzn`hn>2XT43q(~i~dz}y0YhD=6jzBkn2fg3Nq!hA$jDvOyW1628JnKC` z6Wajv3DODAEnSo1+8Dp_s&1Ag$^S^q5?~epk<2V0Ao6yz{FbpITZtQ|hs&PqPVQW& z!feMVZw2a-f_I29B|4obV&6VD>eN;uKQn6R0WXFGr!A00cuqEij3R5kDp&Hivd-bz^uWFaaGl^|EvAbLbr08XCDo)>yWk`m zQMNuDv|S|<({)LGjm}iD_xfT?ZqYQOQI}`W_5ECAR;QAIU#X4~5PK6%l(ML>K<+CDEmhnXk89lL*vL;TNW{8jrr%ACxc(wGaB(nS|L5jx1);q{aknt1T zf?cQfBjUu5A}$ubw2L+9KlME#n2FJK`7_&Yh#u|7_>WRNbJan0{twq0&^seG1G4Nk zHSp=-6KVNZ*_9j$CwE;RS+s>FDd|v*p2gY@@Rbgr*SQKr#ln}RQ{~))t@v&i-lGgC zOn0+afPJ%frT^;D{SFM>#TA%cDSTp?Pqy(xb^IBFuhw~7S^7v_hqz5thsXGNdnoii zdq|y2*;i7r!8`b~;la?u4ioXU^%rG)w`}mCwAb&rx8&vrg+Aw2uKh`qMgK-Rz`V@4 zzfx*(W1b=Jy9L+el^eP?5T9ibS0TYhz3)Wyqnn<ja6_YDw4uqx_2RqG*-FPicE8^i{_uMwkMO%o( z)G1m+bQr&;Qu5UDGAYV@j61Li(`q{wiFqYaGB8$)c&+Yi2-;km@BddVfWE~al+uz) zN$27D1tYFNH6%*PV1V;CYx9CL zoB6|%3hN`bXc{C}J{fDZTXGjiLHdiM++1fyynI=batb2V&zocGGL+J27ZF00_>t4S zxT^RoNL3@6xOudK+=-lMiHwa%CI^ExM zLazGg>5X>WmVZ#3PnmQ9R}%J)^*s|EYR(nn7DMoQa@S$tZTp{0MKDIRvS$mr&XFLL zk*P2ohJ`b|gkJdjz6$6-mNHYkq0@`m&O=Xbr z@mb*z`lvxr<*P2o6=+~dPGeTPDS(^X72SeX?~&{nE)j_>v*aJvN(>-5@KvbctB2wo zxNMC3-#3BL2Kb-sq-wmSI`M)kKon*w=5GRy;h4D+HlAr0pOrpMiV= zg~C0x(=EEG9H}7{jzstxZn5q5@ovDL2aCWE8uR!S1V=YA@H2nl4_X@W=Ob?rsOI%P z*Wgps?k$kDyrq`_0i$N?W?vIj>*gnby8*@@gA0$=1#ZL;oI%`;ZT&cA=CqzYIEgQ( zD|PLL(miEJ7E>+h;%|7}%TBG@gXyDzW4!{|f_%&Ew~nQ?%uErwh-bIYQt|6MjH?e5 z%qxF35L*Ck;-|ce5eZdCa>>`#Bt?qx1R#9dsn3;c8hHJ4>730NBzt0|q1Senc02c} z**~ChBl;Zja~)#DwZ&~{MjjhXJ#cf&^rJ~`y9e*E z0YnQaoD#>_Vwg`r3Yx~#!Dk|=v8>RHY+H_FVFpF3M?Ev9uM*kkS%$Q0x$AM9wc0$u zQJ>i$dd2DZmorb`N}=!S2K=+%yt&VyhQ`q_*qD-StHB_EHmV|-JqXM=&Z?^7O%~Jf z^#8DTmO*iC-MZc(Xz-9A!6iTvG`Kq?!9#Eh79c=34h=LU!686!Z#+Qo;4Y0@<8F;h zaF?cUv$OX;-}%m;Q}x|Hx9V1{>gpyjqFs)KeZhsTN1TD^E&!N-lWf9pg`X7sTx>U?a+09_$S z9Sl0SXgsLBZrerMKb%Ji=8NhRujR8Vf^b@qwp-2+pM_VCG;F088Gjs#VlJHOk(-<+ ze~@*TN%+hqz7=!VOQ|mFGZV`Q1m#0CTDFC2-Fhg&eE;7?b6Sdxt#1FRDDUR%vI5*-6S?VrlJ2615TM#k^ zd`OLE8f%is+Ldl-LvYQREJ+AssV7Q?sEq0l`muzYf%QdO7Cz=fCVm8HbX3$2csk2N zR>#ap$Ef#f3b3HYW-7P^LUew7fW3}OfyT5z#_)_~mll;Ovu?37l##oLfipmOOkW)Q z_;z%db|=`0p-m~Gd{-j40uNgfcMtI6aAinrm!>zi>iS}aep0eH9L#ub>VhuE zPa_)1jHs_dqiN4wi-L7&dFWqCn^oVfdtLIR6t{Gtco95pPb0>Sw=DMg;zLr19u!Tr zfDZw5Nv4&LG9BuO%V@eV85$X4lqs>y^OI`_)hXG@7hAaBB*-jCw|6K< zUVRJY(JgDrjR()jQ&|#gl2BZRYX|ZNwH`}S3r@6G<|IrG#D=@D+MEz>IgyC*TeBA` z(;J@oi{G;w|TGuO==>5xN^|%z>a;zlT(!XIx}c= zqy66JGToW&H-dbSO6W)R#FW1lF9*!wW7X#KuvbeoQsIad7k*>-9HpCNF!cwR<#=g@ zQ27vW$}y+7sx9$!7eJXJ%<(mC1H@`sxB|*l5#Kx0wj^=y8r^U@60`hj-xQ`up~&_9 zRXO_ru@mPGF0mZM+dAn|rzB-@ab#rJ?ROO^OaX5*iTw?$G2qcPu?snPtvPx=7R8kPm($~+JbAyatt#XkS&~r($mBr#H{EDM|6mZQX zJlsZ{Q~!yCcrk>3xX!3VuFaGg(|p9S4W{^(MDyyA{@xW!GQJW??8B!KI57Npecljx z@0}$W6uR#P&)cbDOH|fVO5z4-74tDyzme;lTY13D%J`O=fvBA>R;RYjo;e4`7fH8| zPLe0kbnv>7HGsx2&Y9SPSXY8$>P?>+YU_2;E;!8aj0eJR;cP}C^vilUEZEQgDJn$8 zR=-J`X2&Wa8>bC=Ms(H20>Gvt9(m-{mRI2&Y8(h#d%#jZ^K!kRAV@kGKAL zSqVGPF%6)Mzlhau97zs4vSQv&s2@LCTyR_l!mX1jz?68|7`qCPdtwD$z^gAn29SDD!U!9yO6 za?ZI$&pu#(cOaM2QKszMCa?~FYVR)+c}&X#cNa|(**`O3reb~$PrxBP9La1HiKwvA zKI(tw#FA8gf4PmaNcn5E(3ljEc#t2r13eFD2AVRf#UDtWViVo~I{uU`y^Sp4FB!8v9qNPE-AKwhuG;L=WW zKkjS0r$N>?t`v{2MyZ?C53l+jTEYg%G~=I!~-Zfo_7~UoD8JdfucxR`P&{NrP+|Gjb`9M)%&(%x2mR;m-k~AA3~#7A zX;)HxKQTTxqdfYVW<5LAgzk5zgpa2p1+q03yPkFTaa7E;MK9sD`}YZeU&Wruyl(xm z(W=dQ=*BLtl}S6YI5Wlle>Y=7?C>Juoyr(s?ydm1&6hrLKe$1^O4zYTKA{J zwMAkrsxr9aJ!0B`rEkG(%%m%s)xrGJ{;VubkKCQVAj(tO6=o-y9T;7PV8&-CrS_GG zP@-u62s^X$^!kRCuM#hPp}d#8bE9yGJm^{ z(#dzZahaOn790ok=zN3d{h+sU=CWU3rTE-7YwvI$;IN-D0&Y%ra3ZY$6rug&qwQ|8 zZO|n*2oAf>2O$7WE3OLm=NFY99T|HcDN>c=iK<&AR#?6~PfobNL6zCczIDf4n;%8L zR;y`e`AlglnT>*fFU(I3QWH%a^)HD4jyOq@V&=Qpzi7_LKHZ>gJUkjk7rOkeM7OXC zDk+^p8(!u4(r%;t&EH)VFt>whOrn-cINL|5Z;hGO*DqDmE<=MJ9&XZ+pmpJw-6wwc zESbynh)Rl-I4Br`>afCLHnJs&H5s(LTtT+SeJlP`=x#thh@kuEPdN;nO&IzE(C4Ph z)o-_kcv-+gboi8A5fSjscM};S^wsSl{WDm;5xSAt8+2vn4DJ+pqFF}2FPtvh$;_g% z?n}{UK?g);M76QPNBFaD6x)WnRF1o=NJ5#2yO z4)*GGh~E>U6NVhg=+dKEa<3!>{mh%J#dM(?#12{_Hb1_7A4G27`8otK9r)>ri9}n2 zw%Hu^xNPPRlGZ5ZQ>OhATns@PnUwlCYAD~*P3=4n!&}H?{8@1%=Yl>SX|@z7LO;h& zPZZzp!<+;f<{omMvnh?Hy_$labQ1bQ1IOzGwTY*Ex$26mmk)){GHD(T3*(hGJ9|vGj zM+O7?FD+5vkit2wYGE)>XialNu<0{k%)VuRZF?3uqx|gbUVePz&Pm^BFp3R3}Orz;4-PUXT! z#MN)EA=&UYdvWN+0PzacUSHGX$NY;^?X(WEuX|~S&xn8F>NXT-G>d&$j>!XdB@kvI zT|^iQCPN#Rz9P@&E1rRsCfQ!(B3%{rW;b)yqAqeyU9b> zxk{Z`BE@UhnJOFkU8ODKi#MwZ+gH_^DutxZR$sS}Te>x0?kD?9x;gv2jyJ1TO7$tH zr}^c1GF*_3>GMY2$*QFoWuIWrUGoC}Ay{^JqAhG8%;RAf@xmeR3W64OXg;Bu%u;jbow-|+MCLS;+HtmBPw<}BI_#YxZHk)dgc5LrQmkGvDQjD?NPsiagKaV@reKqSpZ>~NAw?PC)!*0 zBR2?+D}P+$2QTJwRJc|SO|>5H4T{9vqw1UDH;<`Dg~p0Y``qI6T~ObQS7RjiXrEX2+v~pu#Z(rR&vl$pb zHQdj_xNEV><0U95A=l>4KkTD=%E8JhgXVy_ZY)#UcIlKf{@OsvtP*APT)Cl;-0`7} zkB%}#vgR_(47e20CFhNN409jUM@I2l5yn3C9`87wX_r19Pw<;}+s)aAsLy~5khj&i zp#elWKO4B0F&6F#Km>+Gf-hp3-^v8ZIcty|UVyCi7iw$VM(pb z%0wUW_?-sL!q)=0&KW`vl9`!vX`V{c2ecdthMu{n%kg9vAB)n3yHB?ZR_mNx@&;zT zndOD00YPU$AR_n6%4sa%6yvgTl}*fXpR!*>X;0X-d-rzCTUkq4d0AeZYC)b620gL0 z@|cZ*!yIuZKZ(Ks$%Xm%Uzhr`GLAV~T2oV5SCG6WM1w?An9Ps`7E;ZT(bs@za7?q# zBRyFLK#<6wz&*URzCJXU9Xh9Ox}Ur*Q-{gt;j%eF70bh2)(XKdLrU2PX>-FZ#i{HN z36bSuoXs3vjJ)zz(kIpsPI)n|AqGYU9kCGxyyJyhi?x7nE-WO@AoUCa`20aoeUO_aHU_TTZFD7dsTjw>-z-U}{o}@0K zm?}dFe?e%^k-2@!{>G)jX!W}0a1QXZD)MJ^MQ7e(6zIKw(VwCQfFt>o@%Gl@-Qr)>mn7a-LJ1~4UOS;# zq;L;zcfq;_9X&E7niQL9BJ;S7jE_{(O6AhJrM^^vw1Wh_1LUeZ@_f!Jxrt2GzPkBa zhBL-fuB|T5Un}g7?kltDu|BD%O`D54!>B(iVsTfb(NMC7pj7y62aP2l{vVvdZ8Ok? z#c(ZS`jPg}+bW?%p(}X-;r_igsL>l)k~rn+D57T}hfpkB2J>%<5>nKrt;Nj^vktK3Xmw~GxY z%Okw~V0UD?PW7(eYF4FcJb2U0oDbF4ay?iuY1R+?Lw4?a$yvnwwcKFINiC7ha<>ev z+2n17iIbd)9ugt6EbP)2K>FEZqiO#BR%aOhBNm|r)tQ3}r{l+j%UvuI(eZr{_r;mO zwo@g&3X^TBUNPy_)gN!X@vlH*j5|7s#~xuf>XTemGCKYHG@{y6n5ed7(GUpivdi|T zbVX)pZilBsj!>pU$w@MigvMfRYuv5koK)poz*qL`gz^eXX+tXB&1Q9NbF7fv@_Q+o9uc#5TFTv7NKCS;nXzL)18c_U!tz}xe8xRMRH#i`&3vRa?v#& zyY5k*UfHSG^n~dWdyib`|MA^tkw&cbM*JvOmFxGS@Z-lUCFNFJUF_Dsf$C$)fY)pCm|G>A#cMh2j37i za0)gsb&Ta?`FG47nPfeK%-{;hQk64bf& zyY@aawy+E*NkN?#N*m7NA~V<0+1;FM4{V;E%1qqwp*l!Ga@oqBQoIWR+wrT7%PAEV zLJ;*yFB4*czOq6W9%;kOrKA3!E8SjCEjAxPSGt9#S3K(J$Gj15udGZ@vqVeOS&It6 zpHbq87iK9z4pn9t*)>^m84bDMh*yyF*xUAt{UW56DfAs+F?z+?(?O+`$Ym7Ev_W29 z31P%R*lUZW7#JJ!+%9=E;n8(5cNcEQk!Bbe8%o9#C=h~ercd>fNE7xNni18#Aei5t z#YR%ujA&qgAk5_s>E>z1RU^|lTP85^?oV0Z#}wFpmO^Y#lfnLM62y#4a&?hv`&H7j z(=!aJSlfZ_;3z<%i8M!+^-?V#2-)ePPN0I|C=iDp!j5xNOVSV;Dj5+at%eHeO3E=tsnwO2iBHqSHT2>oCTH=@rUkDvYz)^Hr^jH_*cix z8}za7d^x!p4R_l}NibSaOYp<(0wW>}k9cpfwz@{r;7mc}pln}IyhS-S>TVZ(Qgfg{ zEgiPG+oKIQL_i+98}Z&o}n%V)pD;LdwmYjQN`A9Fk6Y(W@5zlEkKZun*J}EIjS^o zr?*lz8GHAC(;uN>x4{;3fSY693&JMuJEX_5x!%_ZLiiDjIQD+{RaH#3_i5M3UfugC z+m2$BH2|(ZDGIwQesNTPFbZ=+Tu!})uailz_ogpKQA59@8Y_sF%^FD)CEjeHj)!32sucNeP*=l=FD z+L`!bw!Rk7FLwHJU8TD}C6l>Yz3Z>tPy7y6v5fsE1Hc*wUb}x$>py?LgVujPVtTm_ z{aqMKD2h@oIlYPZ$`bw)@%!J__H?GZ!kF*Bz}^?Qx*n72bEVW+q;W+A<41M2kKwox z;23+0JgrsyivbxV8O@l^U(_S^(Bjoyt?dJ&eYZ!S-yMNTC`aXG;F$VKGhtdpD*2v= zoqK}SGXFFi+HA=Tdq_5A9ezwBCHP;z%_53zX&+cTa6?@%pT(T0TJE_^KZ{ zruA3dFNqB`kCT68J>xX{{r-Sz^KLd#Q+ds;nCJH!_OB0N9YTNqrbmz-`#F=_y4yV`2SV|{|{LWgc#`Y|zWrq53 z8~ac2{;jp>vydFBJ^}ImyFT%s`YQ|| z-->zqsY+1lu`A_IPFq1w+ad$0A9-W16mKTLp$Y*~lMCc~jr$o~PV4<+$IHeX z6>5R@?Iu_Oc2zT?6?U`CGkkV46$ck2Fh50(Ys(AMG3`UB;;q*z9LqoUx=_dM28Sh; zrZWs#wFd_-c_xJL|8paDM@J?SPHm)6`DzK^^K zEWHMZjDr>YfMkKkYbZNK>Tr?U7%sEUuXScov>wj;q_-|m37J3)vb@>Hs#u>dS_5fF zjC_uF>kv6!I!=;EuFc-iZR)x$(&oxntFgiTJ5RGE{%uL}KE3-f0!e>ksQ*KU71CoL zILLAgZjI+~vEaG) z9zS^zcsW~5=r-pqqf^+I`T3_)t6aRl0AUwuYx=Hz(c*Cy)kYn;_)$!O-Z!e%<;$^laAdu^jFqpi2x| z3TNEj-H6Bv7ZjxOIPe~>#iH?Wc0V1T+@T(I>Y;%zRvS>xR3r+_c_L*@mU=8FW{o!^ zqE!U>e^u=Oq1ssrfljbyUIX_9-Qf(>uVnbX|8Frd>TW<6H~uRi{zo=5<2M3vf>hiB znSk~seY<)^^2?6+ad9%%GYVVN%8g6^JCJmhYq2U)Y7hActZJOFfUrMUl;Iv+9Kkx7{U&R+jR?-cfpX6|-)^}Mso=Der>!A&Tq!t#zZY%3=EmfUsP zI*^t-CXCY>ht|bRsmT8mJ*VkEu zgjjpc+K*hkPVZ3$Y)Pn;%*NjwY|6O#%aQ3ai5_i6!)qi`Q|1iM&*6bIMB#$JT3!V; z?GkwU?K3N(c0A$X;{OcL?=LYjcZFz`|F+O4Xg^2MGAWo<5ZooJF~6OcB*RG5>eZ(2 zKdSDnu@F<7D}Ts_yx}ie$B|G;qxcTH$-jwq3AlS${U&H1$=12vKjr*u!+$rE4+@y> zJS|qs{>t+1JS_q>D%;`Ut3q-BVlsVmIK_2+u`ShYUjKmmbidi7=In=JCV~Y|RBR5p z#OTsx1iJJ$Nv)i~HtY1F=3lVAuG=3KpR=ETY;vlpO#lrH!`8QJ)}NuzP&fyWvfmhI zX!gayVfR&W2}LigoKqMAa+gQq|5;Rif5E>q5u%OzZ>#8fWbez+L0Xmh53%;6p=kku z3zrNLOtF9dv>-|RKOX{=6M+YM6J1Sk7IDRc&V$($;<}w?L@^#=K8f@AxI@rqtuGF9CqVh#_v_2PIy24qT zNUrK}d}HN=Cwm~UJC5a=XE!Mrg*1IDI*#g6=IEI=yCa`cYwy#{By)9!U6gQwuUy=1 zR^5?rW|@`Ve@4+;kF*Gy5x0Q{hY2_)@}1aC{xkmJyX*M+B@OOY^QL=T0}L3*>opj3 zjMkFZEi3EJA7ey46L3qWC@?o$XN?%M#k?aea&D%GpDKevHGsY?h3 zSkzJeU3{`FbzbmlaDAZvh7I+a$J{FI2?uZFhBlzSG+Bt0o+<6U=>QbqQ5HPs|C5^q zXERY-KHj5bw(Zy-Wz<0;UUnEzDEK+lb%%^Jv01t1GzZoO28Mn0<`ndTT4ZsL-PxGj znD73s6A*otM@=$!#x~gT$nP8s0%D8PD%z0n2EKNnGkzu5U4JjBO{vbbTpuFj11V$rAVp`vqGW)O*TLjtu+dn{vTqBg(hsq)#mQ{GB$E}%WQFH$or85iQz^%Zi^*%Lh+_!;86f?JMSO%*v zdK=?TYtq%@3Ed$*L<3>MV!#pKpZ}2Rm+ubnOoPdoGi|0aMq4x(mmA6F#Ont zY(lTjNwBwg!!?tES0qI3tNy&y)i;sPyWZiX;Y*nCfs$eSE4rxN_IwHc;8~k_Q;2r0dmPo=e>Uu%F-4T`CTHv9sP{Q z`1ebcIGuNKfJ)?VI#FuQzLEwGudC^y3;INHVD)Ysyl_-CWaMICT_m7HK7u1*!GJ%S zcXf?L<}RwH+W@fRjl5mv%LpaxE$=pn@IB-L?XBaLkjr8&SP*4X4jopYez>bwx#(e1 zr~ImBV6h2YbKYF-?@zi-bnDO)3!m8mo*53K_O~8_CxQPKi-YfCao|6DrXmy|)h=y)QE9GCw20x#P<3US@5#+3O4d6*V}`2VWfP7>>^{d`fFLee)C-Dn!fj zSXO0BKvr_mNfOpQE!Sqh$*U|Aa#pV#7a( z9OGBw8muHJYX$icpit{ys_6mERQakP;+CGhD6y+#4W+(=>sh04KYmco{JW7YQDg%g zrMix|vN{=0`cfv{ap&55b}pYGq-D zY60q75nu@~&Nh@R?CBLs3r{2mxHnl(Sija9aPdl>1U{_1=#REY*YiOFR@R_NiUW?4 z9a2S&VM4kYn?cFOKi-f&DKE5S2L`n~v-#Q+fOpNzHl7a_ymK9Sd>;#?XV<*gV)!cz ztKpztwHFx^=&-}zMN*f7N{G)!%p-q^qfHHXh63bZzSdR%73A%E{Cz@4X55fas12|| zlWSfjRV09grio?7XYVrJFBwqVxHlzo3zpFVOdFXTENMff0;x65!83sxs+Uev)?Ie@ z*K7t{1d80cd+sC2UFShqB+WiFt}}McT!^l#K08!dlraAG-*QIQ$UC~m!9R43N@l!` zVmD*69#+#8C)$W`1~rjjD8CMWVRb)4q}ol*EM@p$Xx`q9;gvs&#Kp@K{hwx=Lsc&7 zChm~QGtu(hy7#m8#d7SlX6pVCm0m2Q*CDh|Zq1|h3Z}9rD3G2!UTS9Q&=M4k1+(M) z6rkl3B$7uD3a4i_?+(H2BP@n>TL&tG0YdBjt){hmc;ul z?j)8e?i=5@OnM8g+E7F&D?tR$%oW~_g2qPPe~OnTSN#sKCW=cv%XN>T z;o<4=7Gd)PZ85}-!F-FHEUN0tp8__9gHi*m*PPJ9)v(7!`X`_3MJ}Vth{vx5H{K;Z zsZId`!=JJMVlx^&7AxBt)?=#A`A~Fmiq@OKj|97D6-hf6RdGJi-6b*F0#6xS&rt$B zFO-?kMJrYM9fecVA0DPVQ65(;>ZR?CMI`gktwZi~M% zRQ_vS>b*nNQAH5Y#bSEO9(4`}kiK8rqjGS4x+Y_X59jST6!zP@GNC z<*Q|UNpbJhed*zRX!qk8o+^A9>ml8}f4M$WVNVGT0}jO&a=7NYuY^c5xkL?nt8I1k zug<5mw01C9@GskOWJV1`7va{GD<51|=U5ba1_E7sQSPA}4F+k{cwNjtFnvzS8;+r0 zm-0>1G@?y8^SQh_+e|l{F7IV#Hjim|P-(E#P+vB9Jzbu(dGAs3Oa1)v3f~9} zZzR+2$70tes5Jt@Lh3lT1?t6V6}X~^hym4Nl^NQ04bt&xBBVnI5ueqWVg(8GX| zP|?ummg>zGZ<-@SVR8E3-P?uYV?f1)FFUy7YgXS5j*5|(%z@0wtkdiH3~nm+KKx8y zomcuc39lkQBt@`{$>w?%#xFXVSV$W-bh)QZR_^}5rN7?((hXzokGx*!8St%*@PhFp zEW-4w4vi)!BlVpLVonCHP-vNBvEVm$Q5!6^WDc)qnBkYTRk-SS%ePu@gieP_|w{Cm? z()`92w7Qa()8#r~u6pVi5>>ZD6kQfAI5@M`1t+&}(f(g*|LzJpnj2I4ebotRk+lP!Y z-ax_|3>$e}f3WEvYM0IG@#Q@g6XA=0Lz%f6baT7$rWTn>Yj|5VcHlZ@mj_=XUvphf z>EKvg5-adn3vnFb(0{f2G4`zq$M^Pzzj@G`Z4Diz0SkKthH2at^xSeE!I0z$<3NJ2i;%m1LeLj?RC2`%vC`iOM&I-6% zRD&9ye6g!;h6o!&)D{$DUJKjg+Pd1-2HXCyphixPo;$-w)`|-y@-4i!^+>n&suw5y zxu|ACeD!xQD-9F#D@-sp%Bc3Y%9A2^#ynl7)N((O^!5Q2w(wwrFEq;hpD7Cz z(~F%vp`x^&mTlxR++o4yPP=vti9?)#;-0BIO_f1ZDDGf>@v0Ur(ap*1H@wO9y58{u zeH>%bcFk=2T>3uUgB8QFLd44Fi|fT1yMcE@$(nQecOgpDx+AG0W#V4+<8kWdy?u%H zN|KX#Z>3|rQ#odt`5KwNt77p_SdEKuH(T3n+|fSK=bZ!gCew(f9f33u1ixCW60!5c zS`Hx!SZC0#)}0yRoN8YA@Ycbu@$$H^51Zu2 z`O-LpED_Is+*mAJra{jT&xq-=oQk13yZ}*e4KfC=3>Su8T2XoieAX^2vZe%H?Er7# zRzu30+^nWFafkIi1h{Y>2pc&naD?c6xrJR#Ty3`_jeMVR}7@Nu|7p{gx*PsANPkj z1p&Enax(@eltV1t=2p`{@xt? zMN=ZjL%nnn#dMgEG-!J7AzbikiIjk6)NKsIs$fVVke)i4HylZQsaQ5kF!zC{$a1?* z_z{b?RigYN-Bh(C^-rHJ<`J!(%+4#w#Dq7_V3t@H+7!y2!D|d?Vj{SW{p-Ug!_5An zh+Byw%HatN_CXyiyAPa2@dY~s-0u%JSyn8X0_rsiFacB}YGnO%yUqQEzepWq$4N*7 zv%#uk>Q>MsYj400m|-UP`<9-B>71|kPNXpZ70Qh}ZwHF0a=qmlW|+^lkXPg~3Y_s) zPHvVpx?b1b&=l}e{l1?#<9`4~y{42aieX=PVwyQm8(XiV2MwC`j?U}Peuf9OP?!}| zg>7#p6hyrMa(+$RUZ`}2r?W5A8=6gGwbhU>tZ%UEGLL#+Lr*t`IK-~D+$Wp-p^}I| zH@?9O(iPd0T($u#?ID=INq?2E?DHbOEu*aoNs^JT``(yj{@SyQ+l|K_)YdaGex_s+M61_t)!`{ z*WBaJID1jj_zabjh9L}oxld5!c3bDB(DlW!N2j{ZYHJl=crG)tK3Qz;ihSwPTVZ;> z68J;2pqGWVi6i!fW>hH=HJS_dxKrgNE@>}d{tR}?Z={OGB>68_DGhO{L*~=zbNBc* zd&Zp&9V0(@LV}p}Bs+R0C$>&N-hn`>$;-=i84;0t?f%>I4^4kDwH+8hsKZHQiHN4f zb4*9cnv$M~?>0dhnfw|OS_aG$-q#-w?5BR?_A*pXy^t47xbZVNq9RF85L{G}tA39r zHEZ}>uXA4tnoT2XY$#B&uhXg_)DB4g@d*zy66sBj-l;8qejaWfw2v<)vXyOIImXv& z@0+$!ptc@mDKB#Gi?Glj>{h~PB7SQA3-f(UaBq1pM4to@2-r5#u-_Sg~n+6P-x}|74i1k3g%1MRq zksMJVlQ(gwIH_;to+IaFCT@Lke1hx-=zau}-~VDl-d4MP$xY$!?h0)3hZ9{$_hYqU zqa%n;Zu0fRR;U9L(3K$Skvyc2(0AV(GC5404a|E!m@u|B$|{zu^(#eRwj|#U2t5@; zXe{QGX7s1z!Q>bG@z0et9HPc0h@hz@`$;$w>QUiuRRKi2^bzzaGBlbasRZ=)(o;n1 z5*G_KDIKrjHL_rdxBjv^tfm1MnT3)SIKETY40bPCUfQyf9fSnC9rkxA_^FCSK5m#> zTec8Pc5S6GZ?k9XpH^7}mdYBM>~n1=C?e1O`^T=>ZHc5(o4VpuUVU}yCSz;LE6HTQ zR(VepZ#MijxDPxI%!%BXwwDp935`=1r44l~rR&2(qi?|az?uejc6dECPmNo9&;P!g?5%& zvDgYU9VthO-$RF6Y10jURwLdOKk~$V{74TR%B9k8I{Dah;xy29gY-bs))zxwN`|8T zvQ$N`trJx7!fHj0MXEFigEddPuo!gSMR9ZxC^s7ec0PRIH^Dla7f2r@ob*LUP6NY7zjmV-T*oyHmKBU|h zcQ=|;9DTC=w*AF+B0PC{444-Pob->|v}q9^>k!Ms>MtHUA|x)LAhn&R(0TMNCfT1- z0JeL})Fo*5q?d8#{tHg>h_QFAl%z@i__*U-TCK}+JjQcu4Ym{o3!0nio1$8e)QI&`5u=BBP@*qyyVQT(D!cV2B7=z8i zhNqzKJ>NQ8EB8o{P=@w?sK^i}cc{q3^qZY|#R`~}3mK`|Zq?d%06fg!uB+!bZqxH5 zivv%`R3az*^%aCXFazORO1brk-nb3?;U?T4z-c8$!OMo~ou1WFAqu}9azkfqKu(-@ zNC({8zunyawbZyJZuya{4Rxqjm&U<_t@ql6vUh}90g$$xYhoL6%iyhmBtrnp^X3ig z2{P^3&ZM3U!;o4S!>=Sr3o&-=*yy^X9eHS1W;NUvN7$PxxVZTnj%qn`s(-e$C6{K{ zdFmY#ILM?FMFe_Kobuo`;|scWjS1ag-rL(DM)PD7?i5+H+MU9jl#ivTC}6?66&$Cz zyhuieJZ6D8(6RyQ?em**b+Yfvq)lBBqT;Jz**S&J4-3G3&bda_Vs^_M_DvO)e+Fm=@-kTL1KD$))Hj-_?=f3CH znIPGfefD^}b_8!39UGrc*tgY}+Z$JWw%C7CJV>1T-frL0DFGU(lC#W#xK`sgQ)w~? z;yqF6{LxJHpax5O?7?)Q3sqC}4_~@LR2m8gs_LKsLc=ym6dW5<2 zRr+OCEf#imr}?eJao(-&U~M-JIASc2MQ9W6snosyqrZ5>Jy`)*Pa9nu)K&RB==0`1 zqh`+^+Qn%fIARDp2OPh=bk_`gMGP^bANwq&fd&_7IG5t`-c>Z4U#%6XJ)>2EoJ&~g zZ%92h7hs7SWh5mHU3lV@nCMHc0B@m}YflY}|Tg=jw zV+hAd$VG>5WyAz&rYrqPe6vYHmcW9q8x^I^(IQqyPYmz71to>6AHLkCbdL;J`pMLn z%SH9&;o+IB5~KS>9j*E= z(9Ly9&FUHHynR*YrW6CKYd2Dxz7#+;Z z6C+dcnBAEhT@+x-JzP{r)Ygwxoecsw1bdzgf1fU=ckCE^?&Qq7WW0Mk&<7zxZC2>Kum4U^^ErTZV{cxoYS@)|vrN2(2ptR&ZhX^E*3l5lvgK)NLP`2*%E=h%TBpB?0q1db z-u5@dIpM*}v_FCN(=j)l9t>`)H~yd`doM*ms+=fQ>KzbH9BH{}CTV35WiiWv4wdo& z$zV(yG;U`pKYL9MIL|bcx<-R0Sd?;9wUATv*8@k#b}@Z1AeLg>ZS0+u*Y*`1@AG8J z)LO1m1+gL~Sat~B=+NDSoaG-@WALn2M;x**Fvj4pwY4%}d|fQd>d6(Rfg*JzKb8G* zy@H4pti1YKZsM-Ggh5+xl(A2G;`)`J2=u@~=!#(qj8<=rj&EJ3NyeU`8gV;cO5EH{ zc(t&ykG)B9TW-iWt?7otBckGV)u!`geFgZC6p$jSI>SK+p4>9!C%h9cm_=y8woG+Qk9V#

D+Ox>v2OFTmkPa-Z!3A9PW&lyTwR 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≪B|~4j6D>!HW`)Qq2F#6vK}8L1C`>P6kr~& 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

(e@J4?9!u!Tl!#q@|34jabA5!44Mg}~* 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%0ADcyUV?;V5}ue4REU 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 literal 0 HcmV?d00001 diff --git a/docs/integrations/.gitbook/assets/e2b-new-tag.png b/docs/integrations/.gitbook/assets/e2b-new-tag.png new file mode 100644 index 0000000000000000000000000000000000000000..65a0a767cdf34a6c4fc4d88596baa7de8bb0daa1 GIT binary patch literal 47736 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]f&4uB@P&* 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 literal 0 HcmV?d00001 diff --git a/docs/integrations/.gitbook/assets/get-repo-dialog.png b/docs/integrations/.gitbook/assets/get-repo-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..813160e45cb54cd50bb8e6eab220d6957a37956c GIT binary patch literal 69845 zcmcG$RX`kF6E;YK1lQmeLVyt59g;vGI01sYySq(r2<{HSA-FTR5AHs=I}8w9wt2tZ zz1quv`-+}E-F;4-vVNW_LKWmBFi=TQ;o#sfB)^L(!ok7kz`?y5Lq-CAk;E|l1PAvH zPEzcPvRnGmvb)x_^9BS)`0fv~-R* zEH!6mWaoyf+2`J)GDJ-dwnPbjl2sl=Gkp8%TT7#f4+tF|9-XzruISVhEB2Nm>(q2d zfPeMcE3zUisUeFP=f5LHV?$#OANzlQmxTEhh5UC+e?t86zX8QRQFGq^cPN&;75gUk za^$Jdhy4C;-1jm?Z2bTGLHX}K{cm8M)EDLd#)(-Z{C_0JY3DjhnTcf=ED$pv0i_NN5KD(1mK6^SW6YX>&I5I=j@=|DbWBb^&JSO&K zCV73gcU;EvA>Wy`c*kNy+hbxfBM0Jj=VoGGmzUdcTCSwK%xQ%ssTwA-gi&7%n+IRb zfD`@}U3uCVeb^n#*xXwe<+{ssw7|wd=d^pCzb9h-BQjTOE8&lHlCUVk!jdE!998LZ zeC^e^1lr zHihj~@L$$d{t*z2<9e4nV#1Kvq_3FQ;{)>HN^=);9Dl4d@)CpJ#qL;@1&5zQj|73CP(qu1_tYZz$3SpL)g?D(!Qd_Gd-4 zzuNwJbS5`j_=Ih1OR1%GXo71}GtdKeR8JR-=~~yts*2{2-;8R;eL)sztR@ zvV@daFE=gsR0r_ZTQ7GQPDJUk)_$sg0@k3Ghz)6RcSM~7#HBOUrrp^ihTlBejMaby z8E4tzD@UWiw87``%@WBP<2r`^Y)IWe0OpC8Mcd}}urBbB=~Y`^qExUN7j+H`K3$vY zQPl@bJufyV7gFUoXJazzh40__ht(s+lkpCRBFrbiT9c=ekt(uSDa&+zywh2`jDLHs zR=q4pp~Qsg0cN>aIvb@)S|IC9@oSqw1(#eXveJeJzx;6am=<_sjDg^nKiT+7g>7)W zR3RER6N*oII)s8m&~xV|o@T<$KwaKgaDvH*0EgZll=F;B*$yE|Y85GHzeJL)K+UI~ z2pyO@o6}wfqgG=`Mzf0{D-o(@bMrz#U`Z&b@2`LJQ+ufbzm5Ukt` zw^}`&eR7RU){p6QeI0#K=?a(0sv({RAGOoSjjk|ahAV8`k0s6r{Zxqw4>Ucr;pGLr z;gR?8zVKGo`sE0w1!CJjGk6hGNL~ISYAdr@mf9np$f{6fY-apglrUgq@VGd$#O4&x z7X~=^_)cIwmD!?1{Fid*Nbn#Jhxg4c$4DQ^s4XyVxflzxv(nJLyssqv6*h z*Zg|$Q)XdfJLt`W(5orQHu7gw%9X3Pj?@BnCR1VsExJ#zm|5@A-o|p)PxFh93l6C+X7IZglXkYy zUiV;2k`uc4Lc8kg(QB~JeiKWq5Y^sikN3vg~OO=*R6@NxI}WjqMDH$N0RGbI`8 zv~BhzWDvQIaXsJ*73Jfh?e*#_oAGsIiG3fJ-q6`H)n8vDa zE_!81m4Br4K4yBwD;3jCe*(OmVpnChKU$Ne&x=>-m^ApoUOQhOE==$k;-62dF5rzc zRRHYa<(yh=w%R8HQ-jEx^ zDXk01FVrP|2clLD&aT!{%SUMDMySAIciVQ$?T*(<_54Si@cROH06S6);ufHT4UgdkWz0PqniQ!_d{(63YJe0 zjfIDUb{0&o%<}$_Ke`sA+e>P4V)XPZtJk%v8cWO?ZAvIZJ+GdCj7Hs}iNJGR^iVE& ze?9TkggN>*G$f=SU;kj5a2`-1G(zSaiLo|7BId4%TK?JiJkB*A1ac=Pp!?g=bru-9 z6&l@hC9_qzIRjrC#`KJ`+QQSTk<8@&bdpA)-W_jiX#tmoHzV4usRB(9R*+i+LZNr) zAMRw|l(+|0)jORm)uqu{cl~mHp8~{(K)hZ+=%~$7nB*R&kB0@MMBg1Lcb61U13IlZe=gwDBP3#Ebl z#^e*0l~1zjG1{F>N16m;>F(5W#c&a#U~7l{va&L_P#BHBCgWi^eI5~B zsOQoSEE#*qVimanzxJUl&A0}Nv6*~76ZK&+@}FRAe-q6Vk$S1K9q#o%l*$JWHyR}h z-JYrg;eXwd>BFTqR9AIY_#rS?iGsAY?-pLKQ5NZvJ zQQbU+m@j-9SAR*?!0(R$QG6CH=~6-W1nhXYIzS%z&_!vzapLb`Zx7hsYQEOjTC<%- zzj8zk47jcuXL!nXwegwBH)aCi)|w(lQpT9DjbJNi)8OQ96$O*7rG?U^ms@zwMsfx& zk<2)P)KN<2>u0LHwo98!U@Zqxdwcdo5q$m6&uD2UtIh1Lmy(@Ny|qT~ zo3YF4bcnw7E88wQ>$NnnAsl?J&Rw+aETPr?j1{Wo5rNvljdAhi5=Y>jgSI7a0yLKX zVz<()lWa#0oOCE{Ltw`MdYmI87!LW=TE6I9v})D7rtxf}4k}%CuzqeWhP^vj>Gu!L zIvARgUzP9=I$3*-CCD03tVBuOF`kaoO&#Lx{eHPiK=oPA@T9;J?(-*46`Icvrbm*9 zXCJKkYc1Y-g&tij7?~0>4G3pxdoBD?oArJ|rM9hfT4vJP-Iwk!+q|7CM57OHmcLqW z>PytUE>BrYho$>>?JJBAW~HnzLHFG}@1qlb&cs`yz`p6XZy9zlh4ezP2obK!7=0$? z1P)4_xpmPIF3v!+_=r!s8-v!c(ZRtUD6}x0=_PFzo`pNtO@q?UlW;rP>6h$>#Kag! zQP-9*xvH~#of|e`#D3a9=nJ~FyP2AEw_9T5-o012o4Tc@T@p?rSMQkEzT9&csfc$? z=3XXx_F{R>=Ac*iD63IBY%tIx*-}LVpEc@F-b$2<4&N3h)`u31fK{^&xuwHMlb(aK!oJXLfKZcRDPuc*U$=N?XO4KxBbVP`5Wmu`-rhqQ>l6I#E z-0WD|SPlln-WHm4pKI;8?5w|}B)UKRMcC8PJV8Ucj5+B#oyn4LDG-gY%5MFAxHvTs z=71A6$DNXJB6k+?v}=QiHxsTa9x=@lnC}Ez&@NWJBA-1i&@0-`-|FaOdbMtdz;Rma zg$7IfYbPcW`9vm-x!gS%1&6B!=9#IkRzj#y<~6BeR3dWH_FSZU8(uTK)PFcvSNQ0B zOngc9`EA&0$on3={`r9iI?u_crmI@pxi}Owmsk`xlAyD*7QYJ9uNsQ1?C~7!gq}z) zroBwF2O3fz;pzISJ!Y|o9LJ;$rh=UX_(hzCwQ{<=2ZSpJs!%fg_}7S^;rzu%^o%W7 z;j>aSZ7R_-+oF@i5#w%`4+U8*UZbS=j2^ybeE;4dp_078m?a#On7>h(a7-#*hg1Br z?phsfX#&iYcd9?op6K|_p3k;a_`3?%!KzJiK8?*%gxxzEf~k7wD)TmkP`%ob5C)Bk z4&)K`t-R%-=A)om`Zwn4ek|X;r`B1O$aL=)V5}Rjky&|!mbjpUHP#0LcbuPtrvIWL+t=f#!CL4WCDFR=rG3M)pwYbb0)bZumET$zct7dT$ zM$wp?;hUc8ss1|8J&^S(-*ut#Nn2bSt)H3!frnjTy^B#UnR_QLiBg6r zLbB&&CbCM3c64`l@J~=?M{%q4?a{7xscwtEF3V@b)f`X%yX7cuza4pylrM zVf_`{!jTqlIx)&@WIg1&oB8W3d`<ih}q5i`K+gk;xUiHf`(vxW5odzSiF$_GC!y$umOdp@hq6qi9p<)#&;$ zvzXNVF=oN5ncf%WR2>3Y+}+$90z1y!7U2QWf}I$MleYUm*u#UIn?nL&x+1npVVnDsDICpykCUVO^h*}fiUQhGCtyzf{LSA@ z3d#SD2)R_-T6)F7RJD^3m4=QbmB$uSf7zy5poSUF9d4zzah7&oXeIKqL;oi_v-N!p zMpH#2V&k#(?8csXqT$kbbkxz9V1H8_ zc0D#3rKWfSJ8pJc|H_BYB2PeA{pxzh*d`_|3++Vqj3>TT*7(4VuVr`8uvIp!m4Z+K z?aO6W)#>K?`sl&WM0H1PEv^0`4%p0ZaF2I&>hx4nWAtW#rk@O@l$zl$nXA%z^I*+0 za!*u`grG~edXp3SMev_M4wg_#sX85s&9X)toh)kZ|Q% z?eh7Qt|8&eOCCYt`bT%-ojd(*x9eL-G#b{oLf=9ds9tO}ng5?V5X}{NZH7q1gE7d_ z)%v`CM!XUBx805O%Ua3MhpeNs2CzM)=m!L281|hh2xvRL!i;ssG~4PX-`4U`qh(m^ z^8GY>)^Xn8eiBMwDuhZAgqc{Ci(d(B8yT|H%Ja<12eH|KutAJ$xU5ordHz~b&NOvU zQWj&5}^ERjU8eQa@osN9DZxU-8+f3oUH ztZ4izB6$9Jg#KwphqDg44t9y5xpv~t@i6)dq3@@aHsRjZBtOkdL|p6HyGOd*4^AD` z&J^EfsE>{ffNyj`Eb>bvqa6|+_8F?o^!H&lFbAL0XEjjHtBg~UvBqko5S41oq;AjA zO!tc&JyxQZ^rp&wpx)w0I7jv|4fj#)1M$A;p2!(nDyR9dCuPeQIRY^ee*dg%8kpuJ zTc0rOojTvz!LjIkKjcOq*8bOy6Y3d!hs!1u7{BkrZAsbJ-OwRU*FZ!tI7-{RQMLyC z5Gh0<#hMv#m4d)K?_fsl2t9P7_<1al!oPJ^@djmoI`HxSO1U!MxH@M;iQJgcUk?@`c0yb{6J>6;x_xkhz$bAb+ox<`UiS^AI3IL}n;_z`$zem|Zc(%Z#2 zYXtk*t8X9YLR{@uhp5m2kmon>-AKFNPXly)vmAD{vClg)?gt{5CS0y_CsU0!vNjG@r7{WqxQ0{~t37 zb!7EsKVPNKZKVUb$TM5~S&{4Q&Z7Dfw>|2Gw~oHmDkOP^lDB54ib{_roiD=|#2}h- zjmtEabjn11280p5oi84#%(2zZ#AqsT4eAZ^kQ^S>wdW;d8}}Rf@QQ?!oV2twrG3v3 z9n4ccO(Y=eMH%g2$4TrQ|2y4I$3-AUCl|VIill>9dh`l7k-h1C%X0^XC@+fo)E3FFm7s$L!+ z_K;tLWFq??QUcbXydmRk9TN2n{_eba`P*|frc4c{>d*WP*O>ZVMRb|V5WH)n*D)7= zg=}pi78xofV)Cr!*F;?{|7uW#=T2*qMCZrHCBYg{Ralf7oQ~#M=zHKxDtF z-UFNAeiR6?@nTP%^Co0}x-wN4BSE}6>Ux`t@Zj}2#o^S)Q@X1q^0ctHfsMJ5wl{fe*Let;njr_(6Z04zA9)*MeCMc8V;;^iYT z@pl@dh07b>HeqQyjVI+S;$Nrh!)D59TFj_nYd?2al*->E zV`^9I&U#XQDAY&eeop^-@o2Y?t_3ZE7gr*HfaG(P#$H{TFX29S%${wZOHr1btz;Wj z!)8Xs5)VFM_ikhv5EH?@$`%bVpqKW|i%YS~V9}Zj4y5)EmE%P^hE$!cBM*|}?32pe zyzAf>fVP~@bFld#ufie8MHng?e5e7vMmE{H1dA=}g=$Ntt#q?TjI)`vz=+`q#c($E zrF*6;)Cr#`E>_hCCuJdjyb*pF-eE5_PO^N9SZIQI+7PpbM2UF4i7R$eJxFfzHC{P& z+LZ3ze~5i_j>#B&VlGijiMz*9b3@B)9j1WRbh#L*s;a)+RYuxgp(hRC!zB;^qmT|% zV914u;nUH@-u0UacAn#x{B?(BFpQ;45p!B?eFFf{I*+CBTBQUVj8-xrnyXc9lyuEr zSGP_7b-}J1zB5A7`pqQIo!My=)MKq_Z#{{+`fHZa%bJ$Q(`mCm#VEDeYST)NU1E*` zvQ$JI;gx#c>j#4J`GNgX@_(O@OC4b19$l&G4r)Sa+3eg{5yb3Pjnw$jb|9yEURk~f z*u&B8ntTu9+-@ElDPG~qe$oxUX$fk*aEGH^F&%kHBfS?ged2a6cKI64doOShIK$Q! zDiq!Bytp~fdTRdcvpAl`0@Vvuicf+mnr^B(l{>Hq!KNsum z33~r0!8e=w#JIOw{wQeTu4iT@0XXeVjva1m6`65%20n)lA^<1G0c7x~bPhiSVjRf+ z%yyZHFBW$h>R^;|>?Rg2#^ICmdj<4GNXvU(9l8#&>b4*^o(-jrEwmU>Bu!4u>uJgM z^~QbBp6}UjG1Oy+U7SBxS?_B(jHWaHl-umHA>;STn?C;OHy7rsg$1ZXBPiw8SD=OQO}cwk*SY(Adar9#kGIssVH5cayBw0s?J@Zr z)b496#v+f4mybNUG0xm^6W1bAhwRN9)2*{#tcks!bAcpe>)!S+8$LKBb^OU~sUs zMnw@&VlOW*wpts}?}f}aZ)yz=`%Y(YUwDOkX1Us61H@9oV`vtK-r74w#qvns)}%P*Y@TrdhpGi2l!gW@=5j6{o_Ec| zw0OIt#`fo8sKFqiBDj}nMO2=&qS&}%2-NfWP|6l-t_ykCAN5nckZ~t20Qi%mvMx4OF@%XE4Hk&(IXzw~WCmrFs&aaPW7~lbMb#xGc@$z$fY3(vlF#Q(05s z!}*Z#((>{Rr;l*^!#NtAT>5*SIk5qh@J}WM_J%x2=zD{yGqxAAehqKGz7S`FFLUj( zISB*}C2nnP+3io#4DVPpaD>D)HZ@JO1d3=k>B!fZjHtIUMjlBgeOkfE&@4_pXxWuB z&2zN=E}?PrCR(%ml&`QhL<13tq!D!FjSi4EmWvI!U*NB$jfS-ov z{JVueqIt3}?(B>8h3S|xsSdjkWlRcth^xA1dJ!pESTwlKbJuBj zu7tsnVFSDAiuF@|d;c#ro}Lz;e-+?#6i!viCB5QVQeFbQ*x~2+Kc$h9qkkhaUUKCF zZDL)vp>C;Ru2W?L3K|O+ju%Oyl9q11t5cKd8d$|@hXhP!_5)#*K^1mV?lvbB7|$xp zU%Q&F`%0>KJVA@c;rx>11K;Jl+l{43c&_?xJRN`%0>Ib&E;uY){E>2)R`QshZZi%S z7lzh4YKpJ9(i1-xK{&>-<3vI|mN&)E6MTtulE-CNamB{q=;H@*F6fk%{rt+W{-avj%z78^RNScAy?_$3lIBPxNQp`)s z%Mj1+;nU*p-QT3pI?gZ?=e+)UrBhgreu)0d^vulhU*aC`e2$d!ir*>gFZn=fd6q>~ z?i>$49YH8Yt6RJK=lrW;xsT454~>mr3xgbf6ZW(WZt1EWeu5VxjxpFlBKj+cR7=}* zI+!5wf7f*U9sp)=Oi=b$JMwFB{I@U47Rsk9tqb+ur1X7n4W7tE6%-Vz{;`1~sd|&Y zJ`}?8XC)sen_X=c!|C$@Bzv{56f`z@`vSIfc}|*0E zOw}brcEQ*JCt)AEAT}ddN;pr5RS?b=b!;WHm zt7Tgu(@-mI_E3dF5mG=>#UV8s-;U{trGm>f`u>q*dHyt4n8W{QzBs-63Sfq5CMGuj zS3B@R_u>O*FC36>kcv#k-D`PazAEMt2F&>ucaE$c@2l}NNww#2(EFNv^^IWpy{n1vNv7+YJuA7$%wq2 zfB$2`u4vLz1D;PAWhS%TPQMHDP4L9Sk`8X$lBCL9r{H`yT;H)*rGah4Y~ICU1+08c zXlu#wH4g?TOLCZlgty7qK=hM;c$se!&ZHa zV6CWxLAME9)3fZKWvTaib&fWXjH&7_q5rnga^+JD_q=2b&C3H!^52z1YJB##z46*2%NvB ztt;?2b~TgnqypG}M<-HoU0PM>3=9kadmi7d&v}_>=U07Qy(a+bTPD$Jd|1a>M~EKi z1N$elV(GES`usLtE0br9jrcJApoA0SaTLhF7*c&@iNs&VO6{e{-|zm~mH(LNr+$Du zCV_CZ)<(svHL72yD}kqshnau(|CVy8Ja`EFONTpcQ@J@5#ka8vP#dyzlwcg;r)3HEc-q286d?}K z#5U_qFNGMKBIz>(30|-Q zNB6d&nTBRog_Z}8sunX^B0C??P1}q+kYc)sp`=1c(8J(AFCgY&jVzPoj9+@ z$>VWbWgJ7w$=ljw^xlBdn+~VMW<>a1UlQtcIab?cEno1c-LYFwN7iJSt% z>nXoSP+3l?&K6;+%N5%U`NKGN|D+O*Obv$n4ems>p7$z|+k=Y6Nyd-LM|5tJAl5uJ zHyDip>u|j~r7_bfj2E}i)p$(W7xA8@w?0XxKiSSj?GRSNf-|Nj3Uk}IlFuzUwtvh; zeZA|pks12#Uv3UNL&);a#PoEngLDNzs{zEEiE zVgHXYX|RPFXr}ocs$G{EA545RRPoTN`9MWS^X^iP+Nbl4m2jrN{BE=RONPpdAfCE>81#*yjK!1+7Y4XOgMS8z5+LvjW3i#ze8} ze8}Eh3GCs3=kN$zF9OTzOFF35GzU;EyNjzaqTZ4k#zK@GTRx0TY<7S59!BU)s{3%mLPDl_I*XHT zrgqy2gC_6W_)tuap0*%ajHuc2we*exhfz==nl3XibXQ+))L$;Mk@KUcz{Sqy)*}O2 zlBVXSa0T@V$SU9#)E;wx`)!C@AY$ur7yZ&U_`c<;5z!>(S-mrk3x0=8=9J>SEr@!l zz_s&H9W-wj(2_vN;3b^gS$k4T0|&ijD-+PZYU3)^X&_{g4yhp(ly+Hg)0?WxM%*S-;|l#2 z8YvWpLw#bf71DfEF0FQjIF#u9V_+`d1qh;egoLVA3$U%tZKna}jz8vJe3pXxvWg3Q zEJ5oW?Mzz#&ZtF^VZ%n7UEd2@;G;j=MZN924mH5N2&J!4AVrIn%+Pt_^#w|)dNpRq z(r|#%#HZ+{T+}axj?!KzRD;Q1`BfO#hj-TGva!RaeHon6fQq`oY zy&Tyx;scEt+#z)Zy;ThVk;p>BzYw7EV(mOvT2smS4fRIf#&*ci%+z*Vn4c1<9GRKO z+A$*dm>L%UF*x8%ZE&1Ht{VSc)H5Cse)lg$+j=@hFX%j}WFo(aur{+xiZV9H@j+Tr zoE*5X{u3f<+P}IM30>9PIKUcz+pxaB<&zTVZkLNn$KlYLPj6G`fC}l*;38T@n$t{8 zY|SOMa45+0AQw{#{|{+37_u^*C~IMyOw2@91ZeZJUFwZ^mYLAE*Qr};F;2c>xT|Jv% zf{{woZRU-ct>6)E_U~KOO$01}OYXh?2E0rqIm_=>SkTWiH|+f28p6=!=&tBE4&)=t zPzE3trtFO0T-|=iN)?~`15;QiigSw{9*!Gk=J+r|S3UL6b(e3|L9g$0J{8Bn#lg&* zefTfAd7lJs0m=i*q=t%6NNijtmU1ExcunY~{A>teVG@9z=-qyWOIzV#^Jcn_1YGjs z6R!dh0O%AGVmh05cCE;MLeHv~3Sw>W9Bfw@@*FZ@G)IK`k>0?I>?X7hq-YH>Hg(b* zK$pn-eM_~5J%2%ktl6{Bn7Px^Ka_y&rytecWHBG(1_WN3?8UHSHqUson3JFU&Obde#vQkrVFP^4LF;X)`S`%NW!Z2Q*bP(xHHWdh#CAgPZvt_mGkPZH z7b1^Wz%*?sbYV7cI2h8gOZhm0$lJwnxOgLBacEuJr61pWmAN92$S(zH-j-JwFF$N4 z7wjVy0Ev+EfvGA_Vx#9-ewx4>V|$XUCj%&rvcXeBqiNqK)neaAv#+$#{UXXx14J^X ziP{8H=|`M9gtxn${Qh(;Yc{?*8j$aE(s<*$zelsTtz9d~MEH=pHaUz@4j;L#*H`i` ze8;ffJkP;=0h#dJE@K6=VYWU{)h0gwpR*n8+D=mgqfKg4aNy6;F!2|`aCrV z%~Ml+2m2`>e^&p`A9>-DRL1C^X(iiD606F{0k)=}U$LMtnti$Y<9_bvKRGEHyLb!q z0nUAXCk=0pp|%dVbdQDVO^F18S;QIXQ(S~(Xu0)NwG!7934&j_BOYvNH+~F}fl~%Z zQ=YFyIJ#F_C*eZ$_Y0_G(lWmea*(D>nA4NKkvB|b3p9sor^OFcd{=Oa?so+(*BzYH z6|JmI8KommbN)~@{byDo=5FgJ7DVGSGog^j>emexOf12VnsEw2=RAN?#9>( zFVxTfgg1~xVsGM#N&ZP4Dckk@GZs7%NDnsH`k7K(Xz^A1us>t?sum?$Zr&!j;U2WlKdxDLZD^UB0X4J4 z^iGF>Szz>+=jqlW?X6Kh+16)N1eeCd&y-mGT-DYv)g!R^$IT|hH5dAfRZKFP5_?Vl zTHDAZ(sW4;v6hU&N$37~zxx1Y{cRno_O|*%`D^{@WAd{hk?xqDM>1h}S!m#Rq-YOF zN$^N0(FIq&rdXpw+IT)_EVuld{$Z*`yg5eJZPr5jx3KM}97L+xbd(6GTT#a)qzr1? z&Q}f~3tvrJ$}{%vY{ucT*LKZdobv9L{^zp~w`Cpw#IfdGQ(N*0?ZNF_%x<4~DAn{n zJRc%8Iy*vI7&0EHxgp5som^WV3dW}F!4<^iK6=8*p(wgbHlM};jVl~)mb;cDZEL;3~liRvS#Fs)$7*%~GK~3(7AFe8t;yMW~gK|U9!unm{1b6Z+uD}|Br9l=WSlPc{qG)_J0+OR`3jua<7+kq2O!Q+Z!Af#@Aks@X9ou~sXM#fQ=>)RwR?>@YS z_M)HAMJ83TgF@Bv%6I%#&+l**r_vP^`E5@u#fHOjiKhoXfBupyBd3BCDtQ>P`j0%z zgJO4T38>=<%?==L0pq+I>JP#{Jv$&2CVKRiB7~lKtfaBVVhWEldoxsZXBnFq4-v6O zuH)JIXAl#?U5azXcy>4YppS&r zw3W5Fm&)}pYx+U|Xy?Ad$6(){x=$i=2L^>2*!l1DEkB&T6F{Pg zcz!xnC>=OvF#HRAFk(M@ADoIiwyBasHWkFtxJ<6MP@o90>;9Kr-}p@B|G*+2$HRbO z_DhBOh7|5-3)3DLa~;dbp#8QG>)km>Ngu2X2}`^s+cz~kbZh96~i z&3qXxxrk#8UqqJ-%6;?c`kyZ(&&_@f$+k5vA8G*T`SjYau}^jY{a50$d63sGw^z^lSsmTw zil77iu%ZbwBcQe(t$BBF+ZxDiQvRK)hf@fp&d;j_jx&58Nc?d~v<8HKrp~E36Uo@G zMIqBE8cm{pZ|%0~X8Gt(>PtD~u(CrjNMjVRg+ZFjXZu4q;Ghbl*Lg^Nel%b;kN-FMFV#V)&j6FX4m zY9f-t=RNm0On-YZT`aXS+>CAvY&4novGZ?>C^W7+*PIo^32lJ2HZV%L<^upH0P_N` zyivf3x%)mhRW~$2@T028#u#auzj9b{)>N)`q?Dpxuij0pk1%zo9(G zTo=M$D(NFU6_L|h22I#bK@SgtTH0Fwz?v_#PXE|gZq9Z2XlU;RU95Rb(&2dhi+t9grA{}c zVrur656KP}_MM1vU1tCATY_9JijKQ*S?Wr{&`jbSO@6pYVUK*5&Q-tI$;*%`dwEYP zm#v(5Dn~&qAx_LtRvgQf)3KTiKpOdsq{d{r{4FnW=)JI<9d__m>hx6DMX=#>O>E}i z7+mfn5uQ`d-=4yh7#jl{s^4&0iS688%n0w`7qZ8pF6_v7R)80@T6ojmtms7Wx^k9G z&eTBYzc@X&3Dq|5Xdq57d~UWZH+~kD4P$!m;OtOosWp<|^OLU<#L{`Z7?%l9C#eKW z1&2ODH8l)2VLSxIHnxTzbp1xUCohUXZP?71((MelXWcDE8QOx@{ju>t*ospEMvWsNFj zTuc=?8}dKbU?mA&NVdigfYAuKOltE60~9euVTlnBX358cEb_KnlQ2ij zw>zJ?GVsO9079%lDXARjmwc_N%415v#MSZtGSEdd%3@o8^_Va8VO*%0b^Ad!Jb07Y z9DtH-9vo=Q&47=9wzg5Dyf`P;m@Jn^g?#q%ddJCI1zftc`?&Q()lG;)->o?wzTfkB zJ*UlPL>M#ym5-B~qJUo1e*Cf=IE{rc{6J~Iz$-4>XN2x9;q{;e z&NQ>U=X?sa5#T>ogpLD0^9kamkLOvqng4Zs13|@X@*(&|B=S3d6WJk=Csl1}c_Y4SYn_$;<{MM)f09UQJqR8LN<8-G`Qmq;UNwTjd|)47iJG&ZhQ4)di)~=8 zZSXb>b%G)yS(i%1WZ##DMvv2m=9?|_m0uXZt?C^!HRg;k$p<5#J zV!EN@K^ljAn8i|ZUqtKvWpH^TkDck5eDkI2=boVEU;!pgBwV7mJqJIt9Ir0-+3Rbc z7nD?pe*P!FQvBt#{G-s&BhYjj8^={P9bioF0ryaxeHW@l(L%yQTk6TOMA~?L0hoj#oAwEd@Cu=ahHNN;!1HX)B|R1nC~hYP3$$=kti{$lnDB5I~~E!^0| zwYIw~V3FhSJ}F^oNo!IrBQKvSItYU__CH_yL9xQf)YOnY1=-B_-S)L1X3&qM4QVT% ziPHbJ>DM_VFmY)Xy>YzQ@na#!q(!C5`=lha-LabIB%xXq3(dKoIK@ff^zBo?^~42x z@?*6_16S#nf{vIW#Q|n;WCU7WkixIN6=9=Hddt$N{+a!UTSrdV1rYES!lQtTh*)+EODgK{Y0EUp4y4RFJtZcZ5)>g3HlNZyhWZqM=-IHK>d6O6c zy)-Okh5=7JY-*ML<&ARR%Chl;6^2tw6vV{-7R{T6Juv7bPu}WGM36tcjxH!Ma$>NvuiSE%) z8i3XuC}GPT*I5(P1*CQ!DW0V{gBNH!G(;^s3rrj**lSYmAwoK&V*p8A&Kl@#_1n4L zo_^uFU<>Ijl1fdSpV^R#FTg`!PfktFx2$`bzNH6@=PFq`SjllcYsWx(q?h|6#aD9T zcA%C%|s6NF;@W_z&QcV!;MeX0rBCZBQ!x0*OVvwv9l+OSki7P{9<;BuBAVf8*E>I zJKIlC4fFeiS~M38QEPC~izWXZ(>jWhs{j4zWvi#9T|~OVXkLSycI&--d3gr-PlTl1Mu`jxxkhcR);xFr z{i!>51MBK;hBYMV7!4&_x11jwa`^~??_}K9)lZr%)z&wG4#u-6o~NJN*Vhu#gmH<9 zqe{=$wKab~{);?5)|6Tq%)i-8wZ`F6-GDsh9FWiQ|7;bu{3f>L@I&r%>DlJ6T*FdM z=xR4doEKE&e~kXbi?&jk%JQD zB-S`(Zw0s-m>uXJvf!H&e#&(^5PF~e4(Z=6n%@f8c6R^NtjU~gH8|N&``%zR?fI5x zWhLTvNH@zA5~&?HG;_|JpyYG*cjleO`DUf!J>UL?qEHN=q30?smT}fcj89>$kCnFy zGdwkQ$0N@VX=%2Y3|3}hFvvl2EK zyss^uCfx7ww``^B|o8y z;{)_E>HlKvEu-RSqIF>;c#y%J0KplY;O_1=xI=Jv4X(i*f;$O1ID`Pf-Q6{~`!~Gj zo_qg(YfY~;Gu>Ugx@y(soh@9D$Ev~st6*2%mw{^w5) zVVdT#UW~2NODyJv07sex5T0Ip~f*WO{21NFU=71aETK;P#58PAMzbV z*GRMaF%z%!HI{_W{bFJJZG5fm@1e@f!)xBH`4%gdItz4IR?uz%%I}e+-`zUjx(l2T zFLj;pdMM_5F9swgG#&_qdtesL@wAwr$NN8oG7_DGe-L5z|3Gm4My*4QHP^lA$@3Z0 zC=jXOGZq!}(cRx^jhE^Qhnrom$Cie%x7v_Gun?FLm=jSZ+`qSE#N2n+cy?dj&^7rLx@jYrv0(QBPv*>Ra zkRp7^;wd<&gM$N>y9cgLr%D;i`I=R2|1*zjPu}&0{bH~O)9s3jQss!2eg~AFU!@IG z^ahx3v04tZpjOOfTGm7XYPZ9NgkuL)DRELN_!D9eVz7;1z2Q_z%j84;EUxCEY5ryi zqx`pT-_qH3KV`qnyRbN!Abhd&PK+I|)}u#^HLX=sV<1aM;Q?3+?R6RzRICEn=w z-}`w1BD_7Ke-4jzV>>%%n>`-bRyq%Gw-vQw^c_!bv>it^_ST9RCSba=>&I%h9M;a z`OAM>x)44>W90t~*XVd1Rh~yt#=aHl$V zmV(1a?%Oz}X|2%auxNVx{k3VZlkb`11-utv$z`}cuC$tyfdsXj%_Acce3Ru9r_!!L zof{R8Bdj^3;dxvQuBhCt0Gr(Hc%Hu9^Dq0O^l0y2vyZ1*7NQ;=W`qtiw_)E2oeKcI z0e2vO_s_qb5<&(bhYQS1%%__l^aUINN}j+{hobO;W`wM(7!IrD;%D%OmaH#u2cc0# zJlGJ{{z_HrJSVtb_*;}iz04$))0z=Z1mgpFpyi>`t7;bxeQYI!aVD_uf43;qN6_z; zHPL0=C|oDIYhip4!Ec$V+kk#>jcy2rv~G00qLGh^_(ckUtzMH_kuS zd*{8m!D(Fhy{4tN_Z2QGXH_N9aDK%6Oo7g6S=r7h1nJ8Ed_q!HI7^$xO(Zt^)RJb! z2(W%#y7#;mNI<$3qVlb)+szK}-ot#3OE9Z~ZWZm~@areotf&Hnn^ej7Ci?N=gw&|s zi5P#?sqZeSbrEM5yiP9_8My)WDSP+kAJDM8H(+em?GN2}Sp0rZ2nB~{ZNXNagOlG7 zNQvAI&gZ>1_*fHguK&@p|B9yr1XV?3(*IGpBw{`J9`(!+ru=>lRIe;Ogce8KmYADv zm$u57gPlv|I_R5kJuX9Vgz7sTMhX~p$dy(?nbhJoPZqRThPVuv??g?cVQC@vHV^|t-A;VT-JxM%iGI@gI@F#MpqUC)Ve@9?VKo%uLU-3|? zjQ)gZC#S<~4HA;#KrpZJtenOWc<<1Ep9zrU2=QdycaQG>^T#K|>f%Tjalb{KWnz{8 zC_#vQF#bH<%To~ZkFkp5N{Z!5F4}_pe_Qy(kvumB7-flkjK*L>3>l< z;`)B)|8(|}01%C{7_7=_5I3YAiH=iT=UnqY9e_;qF#$l7!zRRGjVdBJF~9Zyb!5-3 znn}1mLBr*L9vRXWNKOq{aAm{Qo^g`1pYP zzf?cuJGr`_Mypxo1=kF5$p3UTc67vG{@5Au`?ma};+ub6tNWsJvi#~dvtMEX}!c&lAu1_L!o6 z3le%d`)d*}a4U>0alcLW7J<-tcXq$a91!}`_aWNzusDr%zsd!<6bP9V)#mLuUDl4N zP*hkVr%8#tyXVGY*R^((35|^YFl(w})KQIrd6f-5RxcBFm8knRJ6Bp+Bp~~Vb_rDm zU*$+31vwmfEa6vt8iQ&&?#PF>a{ERLz~L5KHTVk92i4?c@?L=f6BFU@W1wSZ2w8ThqK4WW()YtIoGmtYP1-T3aKE z?Out7*K3t6M)6p2V&LVw0}44ot*+wuo*$Ioub%mb`<&{WMowS^_|0Y9zut95Hwrx= z%~ijkKc8=E-1qFJ!IUA-OOffso~?PHpEhr6R=q4LoiAicIYJDscqA-ps4|eTL&xs1 z_7&ONaBrU0H%^G|%m1jDl9ZA{@PB)nKd)~*cJAYOx$f!?#mkNN=yZYU ze0#xp_XIBHbdgK*Uh(cn+EEmmVbK=Ij)0*K0sHptw@dE62kG{{M`{57UZV~gz8(O7XLS<6j zx;;ij76o#=`o{J{Grw`Ky zUB*p~L29a+9?o3`6kDC{_BS@7@Aca8B@274R21(AK$$hT^nEJQ+Gii7pP0B8-tzRIKZu6W zY3VEB9f^tnVA}0`<*L^_xTyWYay17HVV>1=>H1P3>rkL)X?pZ<*IM826NNfH5-nUx zD~!h{#eRO3$|03Nwm&oM^ptRA(o_t)d*$m(iB+XSj;gJvG1~k5boYQo3(?Z_#t(YX z+JMYGCFB8`lpLK2Iw^@04rLL*QJF?SdJVG`k%*-w4LkyZTwZvC=@VM}>*KNKKHnQr z9Pg1d|HF=2hSM6KHKzN?QmAF_qV0--zZarI`vv^%f@y)%(;u=|t0|wWQLfmhr!^0Z zwTn29jfg+!e+uKPnx_1hmnGLe(uiP4A~2(M_O_N*7gP!zPcd;QM$CzL8;)s3b*fCJ zWSM0>S}YtUa{OvkoE^7y(r6<3$wt71^HBl|+nBxqHT8Q;Um1`3psdxL;B(YxU9HQt zxD8j?jlL?<*+SVXL@}*w9O)dr5|dpSyrK*`@l%Z{arf?-x>*%=b8YUc-F?#eb0S>| zuFrw`2^rf!YbLPmBIG{+%7g1WVwrvt$L zx!ULkBsSXHK%%o5y_;GuZ`=AHZpD7*d7bmDSUT4k%)4@}iI&Qn2~lCP2|{W|FZpr-SSAF&L1vk+O>fmsk0nUs=IG< zVi9T8$xqI8`YhH0vBnbffvZM?P4xI;y3AozyfP)TU~}q^P|JE1$EhH}=;RAbI%k2a zkv6W)`vHjtg<9i|d>Dy2uac6Ct76co#08p5*<~_E-M5EBJgj_r&nlADxk-maNq7A& z`_%xG)z-Iye^1vWdE zpvz(Sir%FV$C+@q@Ox}*D#9yISW<1_Pk6=$>z8iy=+R)bP&99+aOi(po>9bMRYiK$ zAUtb|3Fj>d-A7Dqj*NzeKd+sL3PO8oa)5j){eFmze+7L*;i^#rx4wb`nY+U(z1eVx zAMieP@7B$`Er6Ag1JU9~mHSz6F)ILu>Jkp}BLW(H8#vBSMI{L)hb*W+0VXvRm8Zwx zUJDzEf=z46lO@t)AJlP4HwhM+zzpEL3KHtDxldE`=X@ea^zvsPD@pu_3PA$x7~LDStOr+8f;}D*iYwX>s=(QL;SO$%5Rv4a^>6n zud&uhz4Vb}d3_Qzoq7dU1*q`LYHRX>Kty0mlwsY@vPFcpis0X>qjxuTXS?CHOl5=gSResNi1uNv!%b` z^3AsTBGjGNB`yspeYmvP~70LnGF<+P9T+ahV*@MyN;+{R*CNuT|3 z#T)34RB63sb?RhrWwuIf4iqTr>yrhvf@ZaOm9Q#)k&E1IbVrF{LqlS^J*%OOrAoBsN#0JrF+-*DBqY(1`{3T8&$mu>HOlXOgJLijG`iB zDO9b+2onOXkJA3N$qwRNYE#xp1sjPCS1-vBMEMc99ieN_|0N{#d=1*4S7~ zk5l;ii7_#Tl6ukaCwghSXYd4CdW8g;4JyIeF@68)-8}sif?1^&HH9ww2d!w`ztyei z-F#sx1)QVkvl1h6`5&J{G!n!IIDg`Sb5V=4rm%~=W|Z+eh}7eXvB~Wu^!$0lj{B2Y zT#PAdP?JCg5ZAszIfwmd9M#T4HjlD`4|OgLcPl_FRT&GR%rve{NSQM^fq64~F;mer z=LVjqh|>>PksZ6e-RuJ!#m*~s`@k?P#wxh@H+VwQY!p!ZH7P`4$HI0|y{Ei;Y0{)3PX ze1HAbQB1#!PA2beAI|btPOKmED0DmIKZl+p4Rb$YNz5swDJ0QAsjh-a8ijPx8o#pa zWdBP^R3=}BQr>4VN9%9L9|s#(()}=J=%p~gNh7@~anayydRf1g-vQ*dyc$if>I-HTNfsA{AeU@bFH+%bGq z(^zN6izDIbOz`Rz0PL!Rb9ICCT7Kk4p!j!jA{pAjBggo;4Uc-7_h>{u1mh|S*^-PJ zIMS}KjPIe2<3K_fAqC0&9C#4}kFPS}G)~i-R^PI$rk29?=#_y-5zO2BjN&^jp&!cC z;%4BpJ)LuSbDDNfM-(*NXYuJdo#>T@rDJ)_>~}8Y;SB}gK(sI0j`F+ z3(p@Xw+@pK26pT;5=as8nUzTm{tq{%45y)J{FbNAYpU%V9yv$lO4WgYW zHdR55_eX6}rD$o3yeO6R=knDQN>HU6Xtsht$U}v{kmDEn=W!S8#T4O;aW!{PK1M5J zOh7ISud(y-)PT^rpqRPuNm@#aV7@A|bq`D5BzLjA$1a77$>bf%{P>}9&!(Bxdn9i{ zG5L{1C3K*Qs9+utMBxxi$rde}VHEj|Ko$W*LGIgMgry@X!uelo4Q|;qqB3eE3*hO2 zMbFY(SU-`kCqBzXRV73+&)1E=nyzida8*vz`>{g4^!SPFqICD>Eh{z2kQDY=-7MUO z!z2T;YIoJj7l3OBP6)!`Db#M#zc*Wo1${fAoGpBU1wN*bvt$-)vnIk;GVW28lvbp( zrMV=istuAW7IKVp{A_4Y3)=y?wWa+c3X9)2qkIqX0Soz~%c6kj7p#q8M`(mqDX!&Y z7c_HG%CPpm=`!S`&4di9KID*nS&E}44W|AuaR?l0)4+&k2W zc7eD_d-##~t}iVww18xF+aBWOS416g;AF-OgG#oMvn}$XX3p!6h9R|n-myPiQIhUD zdKZT=B}jNb;zU_h{QU3~yHc}xCJ#$f^w94Jo#sd6sM79j3$s)qkyQMoWL~M6$AY}m zogk1n^e(OZG6ga%n-W93L<7CBF-Tjks#qIky02_mq;gvH0C8z&0;jabbHtbg)gB9p z6tZnea89ZgV}U49L0=IM_15^!{w+JO;9UG`l|VIsPy;M(bVT0#xHuyw_rjA<9V$5O zmR210;d)jE4Q9SyJNZVW8o9-Y95%Rqq%}`z9(OaNUMALvc6ylKh!b~-kKS@^chwoGr4(Mh zq%w~m&p|R(Rd#>O_d89etN7G_020?kH>s8CBfH?*N@ylZAPAt2lEIk{q~7aohQ zOwuT_vO)nfC&ztap&&;qN~@Zm-x-5gH!{R?i9XZUcR`ssZ0^r3=sQPOD$~(pZd$OH z(7H*iA)A?6=j#5)WTvu_eRbtl{=d%4LHrFn{Rzc6SJJ6bQ~8`$wX$xHz3eKZ4>Vg| ziDv6q<#VaXN`}cLQ7UQ%clxfP-hah;{DOA4KMRZLYU$N)mgTi6jrE=0g^nv=fTBph z=n6y}XKH^JU3>+x963tzCtfrsu(i3`gwO-B{e8Jlolx=6@!*M}ha!G&^+fd!Uj&xG ztF(yj$}^I!J$S2eJ;$_{6s*t{xG&YnbRV-O$-omevQF&oO`nh{MQok1cw%dLK{{*u7I78EM+Sf6_STGG;w09t@rKffo5M z$x!{HH^1fL?ja&-M(jXd>_}VQ_&I3}RFr5MP^M?3T@5ITsV)@Y>>)h$rBy@>JTqC_qimcNa);xti+Zc>ZLkJ55(HBshNv2Q(NGT zD+;XP1{@(NDthh#3eQ1j&vq^AfR9KL4mFnco7ULwY#%a8Y)+Q9f0XFKye6&tlpb+yctr5 zL6>NPl=98|#5E~y#faK0Rk<3^pHNaWn@~*)T1e@;Gc%Ig>iFjajdeO>0gv}2gB26i z7ywS4pLpKD7p903SC>0uexN^ZY?+QkhM~ zv2%*7S_yv_jL>t~k!dLWE_g*Bk_9=yt%fJN5}Ky6)y4Mzn%i*yssQjLOT}JUVd|HB zuPD?LHe!T~J`m$nXo{Wcpt-m!w%4_ZrsDM+&W$Haq{}}Vvwq!|>EYd{)W^+)AeY_aF4jMXTgB3oxE`NZo7oK zhwFgqSEW!rv;JTzQH`W~=(tc(4}o^B(PtR(CPXUxxi5+lfI?%g#1<|8u0P+M5~O*o zbc|Bi`Lmn9UhIa{J5lZrf_ys0;^+GSIP?uv>em@hsjYo^{xIE$k|rIlLRh<~qs0{J z@l(qBO5Z7>_6Hf%9ITu#_3?}vmg*9dXTgr$|o z1f#fLT)=APX#yY)M8|_0YDzS;&q0K4ZBOGl`+DLCxgb93yt5 z*^jY&4O85MHdKt`pL4kX$4-x}#umiG7QP1$LfH?8Y$hjF<#>`nX(>arU1S&)i1%(d zJ6m;EwEX&*qp9}yV~YZBN!|J%IzC@DMI!s3<)n$=;_gsQ%UTJ4QodNw#pVnhOF}IM#qyzG|=+Gc+=?(s)Xf z8V7A@x(m{KGFQY3l?r_n4kKaHI`Gg=f+MD18<}F7jsCIY!%x_r2njhWi^z%YIcGU> z>znUUN}Wj?DXcq3uGrkO!#y#v} z{E8!3mC9eT8xA7Dze@L(aY;>M6+`w6^*(FkUVKUQrPY=76%f4cJj_mrg z+$=57VIJa17{fVKXD&IZB*zhk^`@!J-lBYUOW_b!K8FZfTVQ)c;gyyMO%(2TXgCRr z_9Q~N+BQxVZvUN=?;(0Exioq0bsTlKjOAJfpOK%P2}E#`Jd9Zh+v>ZxDW!*TXWzB5 zGD9)MJPVPqDy#N*S=n@T+&TI_CJ?>HZKL5<=^w z^rj~V6jkRTDX8LlW|LC4V!w=?+@VOMiZPMSNs?AhpLN=7<)h=(P-o;n3A{CKmt{`x zB}Bd3`nkBHoXn|XE{CG99DI2N(-X|ma2iMlxa$dQ(pUU^I**1nZ1)iRp01B^lf^5r z|2tZTsoYL(GaFa}iyvW$#4PE%2jZNM82JvR;l36Zuz{?-ixO6wxDkff7R+w5NkKTj zri4|n(-6E*fp{V=Essfa_0el9iL&Sm!Ob9&qCn1StmtZsX{LpNE9;Fvxu_cQQNGnC zdjZ05N?p2|VElRD+IOF(Pt_;%xPZdcnVp8O2XJEDIXOy05by#-1zgd3#g*BgZ+WTx zh-KF4g?0RpVP`tAevh)#i&CfE5Gr1QmM0Z>bOmh`T4>zj{4oC+qyO{o$U$539IXhD zb?ZYSg^Z=RRRCt+{%Fe#B@I3mzC^-ZBI83~bhTK=&!~PUp5N9j5(!?^)i2GA906iQ z!+EA9v^3&VrXl*%m`K=tvj>ZzgPP?A0@zU{o@dWz6HznvK@3_ha2c0!v_jc+=yrL5 z_`5DnhWy?t!RG(@!xpMr#FOIunxRGRN`ha`qH{mD%k-UOiD&cS6gE*!Hd0ViF#U2& zu!g(^sZ8yBWYgYSG~NKgzUyF4<}W=Fk_`_}F&-;@%!dOL5vYnD^tl(gc)!{*Vw+Nj zNBJHVb_;&cAIy@a*uBkvlrn3?$Ja5xP=&nua%J8!n6ee)0w1P^5OF5TPv`Yk5WRk? z=O3pUPJ+gejazcSb{hujxVVL%$rZoVi@DXK(7g|`LcU%!EtGO4EtIxP;eZLibrOvp zRM<;x{M7e=Ar&yI2NbpQ$czJ2ED6bQAVIe705cYCS`xW-s^MctkOvKpdKHsk+AG>D z`ET{6B>^iT*7@y*-#o&t_UZ-(Xnx7sVN;yTFsUwVUKKz%uaV0gi}_&o>?@;j>w*jV z6^5>h2?!|jJd3&9VTpE*s$|4YBh2$x2_j1z77;n(q;%ip79pg^0%Nw$T)eYHCEW#I z!LP2Xeb9#yAt%R5oR}DvSrWcZHBq=vI9d6AITrnzX?ciBTFFZ}uw7i=*UH)`SXBP{ zul*Vi8WdWkmz_e|TONQER&u~oUN?yCv}Vj}Rav=7m^-4-izb!BL~gi>JK3HV1#<+; z&S|Oc^brF2Jr~V$T-Ns4snU_*Hny-co(QD>Dx@-lggY!kEtDt~a%Lm7y5{qORzu1H zeQvr@aclm{2+!p-wT%9}`k5U7r^t7rUZq`&$;H?{*?b3q{b_YROYq;!Ol#jLlXYJ1 zGMn^-AlVYa!YXaNoC)zCbeZ(YLUmi0=6qdQ^Sx5AUArV=)OAPqKmQgE4Yi-^@@0(s ziuq!#USkW7szg+K$T#f}1yphhQJEx*)huLAOU5rz;>h|EGhS+8DI7zuFK4LdpU` zHB2n?;aQIIzz(0iFyS&A4E1+G@OzNVR}_!OXu-05f04{t9|X5I$S9^X&Zy_VSls*; zbOv{|d2m`|^qW)5_;P2kU*QeSdY)a~0sD=4Bq*ksOj)>8N(-XnZE&0$&wm z_cZ^7Yi$jl5RGV5;iF^26L_T|7$SS<`OZ}e-K$?ZUUNx8<#oi8$};;}i&%{dl$8t( zY!2Nuj{T5pI?hVDu8NFZ+h#AB;Fm6&V1gqUN|7Y&*Qo8nBYF~%6>4bt6wL8mRy%o* z&M0ZI^@fN4`8@tS&Z8YRiCRIlmOBcDF8E|S@z(Wvynnxe5DyRa zoP?Unm-0DYsr(ASPkLgwq8OL9H8_PKAcN@2@h(eh4SKDM?o0D)PbVkBf6BIqG+WAh1_ za|F{Kdf)e`qi6=C6Q`x%Nt27P zXlXemu&)uzx>5N62?^L@LS1xJ0aJd36yjDyrc8tVsjcb6K??o4^j4KJ3{vTuYL)Qu zc0YLKv#X+{{INOtak1j)IMq#(vk!?w3M^q^(5)r;o)z+l+OSPwx#>oT#`A3z|J^?TVt5cq1UdFLaa zFB+6`xocyEXD|!3m#z*Hos~%Gi2scmJ#vZhyqIMp-%a$}HyN7m@o}cOR8toNzw{=W z^2#)KQTWUuMJijcAorYbw!zO{!P!Z7rKVJXN+w=(%k?FCOBB!7)0^vCkue^O)YPP8 z{KxAZrPrk`^y}B%m5P8p{L_w_Khr?rW3*3-G`CuFU)ld zm3F$mWur(qPwCL`4WXu;2{F7hJjUrAZ{$7Iw@%dUy%f^z(Hm;!oTOF{w1iKoYi7Iov+rxMfU#Q8tFIL3DrMGNz$4#Sq82-R(iYbnZSV?cdtwO4dQ_xH7H6EV{|But zpW}^vEYx5}#LLUO1VVK4&~jXHVu!r@(pxU16#^#iIU=hPhrpWkc;1jlI?jFD_XW=S zgKcWqG&lLLZ^!!HPeWLEyXl}QtApbm4*rg%xjCNOsO60R0g*~s2E{gnVd3GmkDFwB zOy52s#ul4JTir~<&5}F0O4poNtjIvMc1tu#sO#uX+|F>cv*ZL8nU>Y7S)kPIhFwg- zuOx#ObC8vV<`^YTLU>w2s$hN@|D<@(0q zKau*<%DLvd*cY8<0%72EpUnsE6Ri49!wp~pAQt$k5SEw|MBT~Q$ZTrQrz zRA}GG2gssUxzty|Tq6!d)aA-+PIkp{%wefM6LnOwA@$tU*&3!{W}_*F`5x6p%}T23 zVg}AW8qrD^v7S$^S;hqkc2dWoKiiCO3DJ9^Cch}jO}KDzVo}S#qx@+F$ml)64(o9N zZErr3wg!(6%v;Y_g0X5!IkDSue7I!y-A_(WByTa09qTl_dGsK{!SRSjkt-Tg1sYCW zV)?zG;$799MBCTmvcUxA=jU&jNK^+ifr{|7hN6A^h3m9c3?M+6*eA04+?KFPvRCpr zK6`jbImVKI*{@HCRA{4(j4t-36&sv^saPbr;&9{-F5zT4IUJd#Q%+dMM`%Kgaxi>K z^%`?uYCBv1?hrSV>+n|HN;zlWC-LV`%UsrhS#mIWvB&7sWY+1@T4oxmcCg6lKj0Afcv6I$MdMX)agPZ?*@0U1!Aq#ZY&); z?Ie(OLkcvkzAjGT6B717wrpkbn)-CoGh_TKk^Q~{jgrq)D}!i=(Vb4NsC z@tLVmh*(j|HbzLw>dYw(L*AUcvX8g%A*!L{(|NfE@Ye=%vzZWGf=gM>R+vC-1T~2? z4q}q4x-l=j!G5pPH6QbgK9!=qS5-AzPMpzVmEZ2QZo5G;juM6>e1aOd*X;wrbY4{% z(YM$SE{ZMYTWY9k-7AeHfy8Cxx#UUf-}!)UqG!LsxNQr5iC;zC2} zbxo7bbMS!oFJzrJeU0F8x96YVq>t#%g zA}LpdIUGo%K4>p}Kqcm%Vts(cTK;Cli_pJL{JB7F()6W-cx)N6|DvT_l@L^Y-ZSA0 zOi}xU5Ffoh-(&DytHr|l$hdCy}c3hSfKD;53K zUzEr5vdVOEcM(eFlNwiMHGb?I>1dEWr6t$CJ_Q$LyajvBk1e?k6Fow!y`^8LyYe%X z3z}7>KNi&O%bW?_aT%Y5URSaawj7pp{q16W`to|a^wSt(Vs4_O8Af&7E@45oJGE{` zJUOk|(2ef0*4d8B<;8igHkz2gc&{BYfB6SdkOC!Cpq5If%lztlp%DX_?~l#Y zK1g}YA4s{4@?j?YCmmo08)R?DY}YNMz5C~@F*qo9kpc+I-p~H@k8Q!rb+q;p`wxx(bkL4ePqIV3V;90jstMW=QtFf>ECpM8U^jF+_x|1k|J10p57hWDS0N5m#w_tv$lvHLmRsT!H(8RLXJPf$ReRiy$GkC#tDDj2yoK)8ZZjp+RP zk?6pUa_Y_JJz7H%w&9E}i^k!D2AUOzp$493F9@7Lw*4<-*W1h4B z8m<5Kgm0uk$~*V#z?Rowaaxu`Wa~WW4UL54zb4+Bsp5bWq0O`E>Ajm-<5QBNNFi7qhXhEAYg>#i@Z23%j_(J=&p2D@2t%{e{6!|h^&)4rLeiL!H1I-go7F#m z{&)VMIY_l{RQC~4=ktF0&Y5c*HrmAlmdwa_vl39 z`hS|?rH4F+P-~rB*x<1I7Lh#0$z3fK8N{)|en7o>e0O!qFGPjdzEgP75Lk05vj%2E zJQrs4gR_%i)erv1AB~YWp1trkgPu-}W&bOR;4jVd`eF~d=WIJGd@IJBxM#TU$SV_% zrrpBZy3lnp!T<39w{lqNIzQ5FCfZcnDc_E>s0#QB@vEU$D}J#nWI3!njD4h$7a+Ip z+HV?`AOAb3-eT@{*TJ^5Pl-s0kfLruztKN(%wfU*z3BXN6L|~<2KM^ z<8SOFWf8lXx>jIK%Ov^X+o>$@@}TThu9|gW%W3@aN_}s2oX5R%{r$S#sy;Re8jma7*QM>QQ`%< z6l>Z}LkDs{)&3$0xBbwQBL9qbk{d#?Yl&>$^f`r}muL48sYkkpgm&{QHSf6E_=~(a zHF%cQ&xg(=p>n>>Ew-@fh6ByIT(zK?qSYZs1qqKzhDO5>caWaJmOzwKfp#~3s|`NX zGnUpJe7n;w?rBqF0;4+$t5uPlhEd-0KN$jX)3805T637xdv!AnbPp@06L<^s(ME~l z(=a;jttpix5prqR10KH9)FKjJtekCNgNJRTX#jq$4SHi+c4roILe5ys&5?z1!(;xN{QzcqNAs! zO$ygP!X^~4Y&u%5Bf@S`?i;-8<&^C6I4-Vuo(6kI-aArZ(_>|`qj+2{PrNG!%xX=Uh< z{)Pq~y#z~Ydj=;0(l6*+EjT~VJAXwQb9KW(Hm(>L@EHG$K`Zxb53hlKynMH4kO4cW z(&jjxSh3F2BHu!kwR1#te-5owe~~xWNnHo}zp!s)vK65E_9(_*-r$wyg=ouyMAa0A zB(t*2)veB%_{3B(l`6rLp$I681EwN?tNxhFx1UMjS&zI?y>pB^F{T){hg=BrXid8X zZ`mt>oyA#s6T$FVOxI(RB1P9Vn7X(y!4HRQLL%nJz;MgJvuP1csbX{!S>^D0um{OLoV-ND-T65;p2KNTeZR=ouR zu4uTr8Tr_2O$kJph{)mbIkP(p)fWhq;Zxb*v63XYLLd0`OK_dU(SCIM5Ui+IN`VEA ziOE9#=fdL@3J7rzC#pGXflEWHi@O|@)0J;^5wGl@I*Kz8rxV5gnJvfuc}N{^Qn!e9 zydEF%&m7$$OnBI!7Rr4zy#%xa)7z(FEs@sFu`wo2o0`eNs3ZnsNkRHok1Ge1KD+dW zdzc8scNEf6LjT86Hy`g)8!L41cKVz~gG4%kzs(wE+Z7{8PHKUOTCF#{PSn(4v*Yj8 z%U^UeQ#xgZY7n*cv8IC622MiJZmSu3&~}QJbTq#@ZO&Jz$O zHG#Yif8VYOccvR(X3m~x90rx`(9;_WS)6pRit z9&8V9q4IwW={Q7^Ieg74nmFYQ_6cpu|3e_whr$8oR1l?0LDZzwWN+FVt!vyJ1~9=s zOIvU;C4uvR@>voV{!>AK%*fnn0%uCMu&AwgYKHX9Rt+xCJG(|w*%0c2g$irt=q%a= z4q}o_b>pFDl+Ys+sorp<0p`jxY_#vUo+W7G3RiMGZ$=H@rG|DtHWFCRaIAi!R2<^# zU#kD*Pqsh51H*UMZUVE!45w;fI8&1MU-_SLa_d*7M($r7NK$&#BJ$`{t`v;|YCl_T zIDe`)sL(k71Fz|cqyrg5aV``cMx!~eJ3rY`Su>Bpq6JTIsKP~Q+$nGB2utHKhLeU> z%KxrFn434I5H6=p$llW~O{M|+iZTO>s`aO?+$s+LS*es2D2*3s3S{TF``xHNHjeq? zbbHdS@wg+_aBmcetRkZ*0P^1hsYI%m#3};5KvoTPAnX{jWNT7Mn`JLY1&|<8`$hpj z)h#JPnlKeBMss@%X)XrKbr>@*oo+Bc(fOeV63Kdd2xrG zDjV$ZqFws@n1|dRM}2I}GM&Rc|Hs2EIjBq_4m@csD}%D0L5s*iLbdS(W}X4{(@iMy z2S(2Jcd0Ean@c%F+I<}s>@XI6&Ef}Fo){mgxm(6g3~r^ew;tRpU`#4YINUQ_rYVJL zgh?USDfXd=5#%>UVxypJN{8PMtLtGm^grzsl>8J4%I(a|)n-zi4l&KxX;8&g{36jF zK5NC@(yr1hAB8+kY|Pg1UYYudBO9EOhP_^3-qiRQcpxXSnk}>&Hpqxmi=gdA8-Nm! zMG5=#Q?fzwuAvn#kax1XGr&%ITpa(9_V|lt@2H{EGO60;BkNhm{d0TllLKd-8Gfhx z^xGtTZ@IFlTYg2`5?!nj&OnNQwsopRSlhQ$3Ke8uZ7OC=si? zE4=569<0dwWL*50JVr5*x5!(#G+6VcjIvs{Lifc^^$K-Ll-XGxZ7B@-AV9+mm#X6Q z`22c7f#F|~C@GypF~6;8CiAz4(U(Qb;o<^XS5+6S^+tGYKN0% z&G98wrj8ya9)b5fZdok+$I($pza1Lv)PoQvp`kX{FCDE@yuy=7QjP1iIEAp{Eng1fuBLx908xO;H7 z;E>=>aCi6M?(WV6x8T9u&z}2zzVlTm4@ez&{nzapW`WkhZVa%{;F}t_}7M?K{4&s0~XO(^e4cP^H6TFhZziB5V z&9G~zfl%$lRTib07AJp};NhE7dd9D>X>^ro6(P#+9gsjI6O9*3lC+XhlpfePSTz%g zX+Tf=e-&cXtFWj?J<4%7O%mk6N}~IXqjbZLHj0-v7F|IZUDbtEkdeqPdUJ&_YmYX_ zaHB{tK^XCAq40L$tD}`413v4kZzMnm{ctZAfIiMm!mek0Z4v%H6{DjA1@x#9NV!U! zBfJc&{phE&O&e)rYt324)%6{j{ZQlOMexuU<*G!MS|3eWGxZN>=$|eqe={bbwJ0>+ zpzrbFj#>2U2+4cewVrlr5UhLRIbjFZNcW#&Ef&G{?jy>S;grkFyD|u?=L=PQOIML} zAXg*3AUQmv_6qr8Q|ho1pYAujg(B(GVdTpLyswenWspOx%J~`OpH} zv6?fwY}qkZt$z`qe@3Ekbeu))q2mY&)p|&BwzeT1?wS~8^i3M+`uW2p9+NLastVmY z_aUm%Iswe|jbeLf3jSnq!efSSUQ=o5QHXKQ_yHEEs{+U!xC&fIk0J$-+|9%rGetkC zawpFI(O#~I#%R%4%{4?zMfn!I*XwP-aTwDN9mck=-g|RneOm?EeW;whq zA!-sIQ==0wP@>1EW}vWCzPk+La2S&G&u?d+PwcETA-_UOqHP}Z)9A6%^X~5= z=X(0+ZLe=ra-FNv&j7sOe{5%1|1EL0G;S7E$^~^L873>0kJtw;yOumlgP9a1&A)Gr ze(QXVAt_7>^C#aabKF*_!1YVI)}CNl<(Ok-CrIX2*l+L!a*N4I2}>}Q!HjYZVqz>w zV-;aOh`zQvZRS+5%q|`&WM!-3reyokQpYysuL{z=6NQwXmhZwf|Ur@pm5b`W}+*_5_BP2S*i z|E#p9YE?5ww&PC*4hr}O+EVi~c=dr3cCNYpjuEPg;8J!4%{`8pC{22O{P`yG%hvzm zRVphjh&{|7&il_i3Fubh`zDRQEpFi(aCSI(O-Zno|gG;^Cn1jdU2|bO`f<#xKd+# zVEVja@bBL?~j{pfRF9CjGs;S^TOSu7=Br*Ht_ic}Y1rz%QE#P7lv1+aCzI}hbZuw33@~yJj#sk(!PBh!se1oBQ4D=H{iikVB;GKvUVnN)6gv&3a z%-gnvB;jt~%W|f6%rL7+uqM@62TE(Ja3EO!N$~Y9KK=I%cY1nS7qCRp^Sq4LU3B{Z zOEyj^!F^|dWA{(EZLuLA9DHJfnA~IoE7#bMY#iKD37#p`c8kWiTjJrN0h}DkBeb>_ zOs?j#xCa?Qy-h8ylBh@dx6xiTDY+hb$o^iWgB~$5Y=eZwYSWgE%V~QkEo(q8O}mbN zwApyYTNJz{CW9n_EQeNhEk!oOyudYf-FmsW`oVjhHfWSbe8b5X<#1|z5G`&S5KPtN z30A)Cr}->mbz92+qh8Iq(no{8%~>q9nZxGxdD^&F4ON1@hqmBbsh;>p*2c^`)McQ zWykb5xyb>m1^7-HwXAAP1<3=#-7%r(HTc>7r9j!9lyH5|F!w&F@3_e zAti%BJwd$vK2XbgkwGGsTz?RCljf62(%jdDpx|+NNb~F-C3B7^*hT zhYz~huoqxaI622R zH%XEh9=O!2j;xhS@f06K=QAa6($rHPl3I!?(&y^uE(^bDemjZyW~#gEJt^)dLmA$& zqvo6%`J&i%zU31Apgw84tcDqgCatDg^roXgWYjeynV*a~X;8hyjIjzvB2R|Ts=@*~ zOS>P)p`?SPa|4OK{zhg~uRMDbZC?T{e$kMveNg!MXm`pm^z8BT5J{CWqzJ5j&Q8gR zR?{ZOGt{(B^y8*9`Ft@t@1^2K^$!bh5Bc%ev2Wk`kLkmDIVfc9!O9Kqc$#__B4 zO{BYt#LLVR;BiYPSnt#=`}q10PW6t8k7g(@LH*$s=an&J{4Sy~4x4&xc_O(;Fg>hi z#Le=CE|>Bz(a{J?{PX+Vn@FvRILGbSCU$?NNhh_v=@xjCTQeL&2xlkoIBW&bcvh}T z%WhAF{5=9rCyTy;(df&8}FZy69BhOL=oynY+PS&ao>D>nqJehbU{P5H0^MEs1+5Z zwtut)lSHwNs!*MtH2`d4jqe&Bz;S(usq}c?(c!G4UNy8R;14j1^?!H2Fy7T*Ej~V0 zdY}I+5`t^}*sz-H;c%ws(|N~aki;KVZ_fF?1ADPrM}?;9t1z(fp)nvI9v=xK1IdUS zZk3iyx`W6=HqEwhDI*YOdzWTNn=iZ-*>xCMIKWJHuES0RWkCsB&HmHxGD?3fs-)y! zhL(3X7DtlJF5%qLq;hq_qYZC=57)a2O*f4jVw_?C&$C>vvtk3*Ml7bIBF!(+ez(t_ zUi-$+_ZONk=P1sFt^n`h2)xFajCGu6!>A4Wf}sHhb`!!>k0t~-Tu>(GcKLN9t!xPY zP!V?YwDh>Usgy9-I5KPjl1;t3B$rHSBC@iy?njfmLW{FJ@sf2tJ^HS!Z(TRD3T40? z&yV0K2q>FZY_5LZRKdLgz`W@l*mwwfDHebeiQmNtds>oP+l)l{-d@dZscw|L&Sv=Z za9j!0szOlKii{^6+F_=rirf{0%wLR=OvyC%EE0HRG8S{8`hJ{rkj%m%(#m2emv+B# zOR=114z)rNNB!q~ezjTsLy3q+eYF!gUt72B&I>2)cCISGJNE+>L_X}mHCBo;W;%mdPB=aQ&7ECRUN`Q||VTlp$gx+X5 zwo|zUDAaA@*Ng0FaMQAj|4xcV&xPx)MT3T2P|FZM72|3O5WdB#29UD ztc3z{8WZT*-E1~fL?WFlc)+a9TrY&pdZ(Yp| z&JX=s{LpM#uq&T*#H?s#gpPw z+bMvj@8LPzQFLHKy$O9%I!uFTNxb)~+623R(Qdxao7!&b^1+lOaBOy+JZ+de1?G3K z?TS9D$E=st;*ij&e#mqkFCg)0_evdf&PF=bT{B03isspN5V)Po-YP&>`~uMLz!XY% z?f_;b*WLbnspM9yq#uEI5}*V+N)#~&fmoC|PYG^ZV}o3b@_KrDiuTs*>er@MYXSwk zIE?w#sSGPU-1}TIIds;X(F0j!1#Xkl@rFapLTdI1kXJtAsV5C%msD1c7LZj2iGAY5 z`A=q~>m>*=qJwAz01lxC(oKYqxI>fuz`t0r(hA*t;iiP&-Z6FlWgF-DUUWDeO)N|V zq{wb=%QE!=-ySjwAq)q`1)@QEyhcn~e;@?DMmf3d7WoiJ}lmX+#wS~>BAC595fB=~#z^jMe0I<{; zj3JJJTpYUMGXZs`yJ(e@8(@z!)^$#K%vS)B;|viC&*`3_E2fzSvbny^#1QoiVEZh7 zgv$heSonGCt(P=}Ad>jy01b-e9TF6TwPJ2(vFH!NDrjPE9l#gz_#g4<4oJGzU4>rn zQ}^yx+C7jU5}!RhLWk~G&9?7?W(54`%pKQkX4$haIdr40?xP)J$y>MLO@?nTcYUi! zQ$vrg)^b{+rcghaUna1Bn~v3`IR2A6?wsqY{JsJB-evf$w?O^#ayrc1=z(c>#&CjtT?F_{X0U{im zh1GrceYjT(fH>s zyVy~BGeK9){3GFkQ><|A4>SINJK^wWi-uZ2zyieAoN9YceV-dOxhxI@2)PP~VD|Lg zh4|fSfe#b}EV1a{oB909hrCa(JI7FH$|@ib*>?Jh4Mp>fbE`@WPf{^6+~DO1yxsz7 zqPp|3%n>Xq5;4(W4c3_PKb7{B$fZ@{w6CHvkg7c>==7qkToXC$V2zF!1{xC9~_+t~gj%}ZwK3zv%s>0Z3fLnn)j*oqn zW=(AoP^88(Keq*XcCailBP!3!WwaD`R-G|)Bhm6_GrNlDjz{eX#*7u*0$wlY=%6!# zC{hmj-_)7QXp?7DiN=uBost1s1Y!@`+Lrk#_=-#Z0o@w#fc6ns zmq%fJJpDTH6>qLs<2+yi#mhI>BY&l_!`~!2)eafy@17`E#5C%>*)OVj*J8gJTkaho zf=M-eML|Y3x;s6J2n1-`7{Jv9BBSA`JqT)cdd2+DQ4x?FO#IsPgrBfpYdpb}OS@hd zwS;JymZ{Bl!l-?tWtlowKU%|E_N;J+MFY0X_hnHK2)HABMb23Km5qJMb@}Uy9WSLJFW48Akotuy8rwZ>$tA zicZ}wRnB@ySkjPm9G|}%iIiY)j0+d$qnMfgI~;&JM}(%VxQNl*q$lO`owCt z>JC|UDu9R;!uft4vNLXW-04s_hD2Ck+5F9ywH&vD7z&sneKF>(eQqr>0t`82SuDzx z8O44;V_@Mv;z0s(y`^NG5nw(F%%GHqqAzNZZRP!+fEhDPF1)Fg0Z5GcfO&1Xdh;6$ z$YkbgcN>21^6mqT0)#J@n5N=O|Ding-J|!-L)%9b6cfM?6xjGU0E&;{OuI!NIW0gF zd%I+QmZlC`T)YO>XPgL{uMMaQY1jG$b8Ars&)dl@b4Y^e%VdJbCd3U4d1?4q>!FYZ z0f-^oV*V}W(mFXe=LUjun!-(dZ$NbCdH5UO?!EdX^X9uex(r0)r+;is+ z`Ms=0;%GqrfQ-^pB%PsoK;_5p!g_#|hb#}HAG@{PLzDg!Kx{_{Sz|Ro`VOKeu8qKS zoVNb$ysrR}>WpQ#eX@j@mIAhJY1U=cGk{Xm@p`^A%PR@>BeG=?5GC7FXRz#%nmXH0=i1t-6AA{N-sdBYu0hCR z1_Zo|>$$FOA}=|Pt!8sep!+m0HtCL+D>Z9oSCQhWZv9B&jt|rG=zvQ%&f7iImg=#u3FY=3{pT1UCkptC6aMLTA?= z+y|VbC6~Oy9q*Q@gylX*2T~463or?TCwOl^Wq8L<2EFY#2{>#B;^xls0vN}!Lcac~ zUgt-^JyuJGdR8wnDNo~YzE^XnYOiz*q(Mc9bYPS!toh=gCnm^wdUFb_%K!8H>`N~<+FWb(FQz{|FsqW(+RB|Q~*3XLq7 z%gKPc@q@U6)PdD;T%!XthfFYUU(*fGtgB0Hrb zjn-weS1WQ{5mAm6HZ9q>^)}O?3BMgV7>c-!h2kong-j)hq1jd3BZ5)!e)9$uUHM7x zW2Q@f*U}}v(_E#NYsOGbsUS2Iw2auh-$S2}m3XBI6SAJ+vD)@LKdfIuk2q+Apm{IK z2jIG}(^N%UZK|EoKrrJnw%p4pX<^gC=sS35QPr2bBr*9TR$>Q5lF`9k< zS~R-D7^@#UHgvw>UbEH_R!j7Nr{b;)d>Pi{u%L6*Qq zgE>;?!{bBEXPosKLb%SeP32jL4JzKy&$D!Y0F2bp`xxLYiBV$tO>d0ffe55Y<{%_t za)c>rG(>95;S^n|CU$&)6OM!V9vSURhA6|OhB9lK%oxbxCpuS;RTq!PBnemxuV=~E z2Hz9+iP+unKbnQ)n?pA=oYBhkSS3@ikqy!ejXR4|4tb`^lk;^QMrac)qHgiAw|dCq zOg$22i#)%I|5xFL^_6fN>#xD1OJ*Myx4APkuJ*Q?__yd|pYV2hCZv$$hguQNN?zRe zz+L@q>o;};+*joP!}2zpQ;Be$@GO^mQnA7=&QpKt=9&H3&M7b2&aeVRQC3$n~|ok{;A;w3o@ICz{b=m zr=iRorVH#e3x8BOBGArA1`yZY9c$t~(h#C(VZkE{0_T6(oPawCYM^?Z$VJYc5$X+`>?<#>6UCKU+~pQWPe zsPB_%m?Y92Pg~8LuC~Q#&^L4pnZINm)e#w$9iRJ`pSLFHsr+~6eSTH+ylN1%yq=9K zWG^~xJ&T`XF$)F{{*&7eyV_u-QDgDk&yi`?wD2cV?CSJ*UhW#qL%S7juzL1Zg0mr; zMe19=@3s=14p--f^|8N{*#m9mCMM8>c7i84A{J{-_P9a^nb#XuJ5r;}C z-9!zvbfVQ6Sl7z2t1v0iD77I2)lJ$d$jT=oGyXhwE3$jbDDXr4nBf`ou5(*roBBNV zJ}Ryy?iBfl{QO2nV=ZdxYP~J-r69woG)c$ni>z&CQx*!M*K$c#8!qpuv<#XmhJE7Y zo+lM}VkC2%a(6F8y^rK(d&FN{)KPh(_uEiIW5a+#v=8<=0q5ngN->rK>#p+o8GJzX z`-eZA?Ge{$*i{{*`g9Je{FM5ZD&LRT+W=`z`X2GKGqkf$|*H>z;Q32)tGm*v( zNBz!`Xk22B&O?yf4s85p!=yeBzwIk)Y~;#NHLlcZjrY?|Qr$kVotC;TJ`Os<1B*V6 zD27G(d{CzmeXCd~=DiGhsok@;C|4yaxsQdE(sR7pqVTT4$4bVDdU^9n>RkwG^dOVe!WMm>i$E8zG5-sjw}r3uMFhud{`n6Whqcmo)E>HW zDsA;O(6rUDMxt+CTH(snYApkcT5?gj0m9#(^roQYP1tz1ruI{Ut7NGk!Pb zN@1q7zM{>U;K5BS&ZeqiAy^u-2;VGR`k2dHd6_0G%M9;23Ru=4(k>y6Zv1>WldW!b zQ{OYSHV3cbh0*+{8VX<~2l=GUzlYoZl}IT;cwF4pmr2DMrtU^={+kI|CS>=~(sO$* zt&L)hKm>lm*~Ue`!k{R{=B8kp4;NZ|%KAj^Iv=+h>5-1b3Fb4{C0AO!bRI}P06d^^ z0z~V1k(v2h8QBe?56*Q4>Cy<$dJZ3o%)XlRWT^UY%%TUz(L5`dQ>?{Ia0KniP0{&} zYV;Su_a|7-D*K#(e{!zkvdes2!|}C@U}fTpY!rtXg_77b7mxcC^xCe)0V-G zIxT1Bz;mnAwm(^BjV!;t{-rM{l1bEbP{zrxqd>qd5>}hcvrB0-!ILDXXrW9<$NUar z`^9DU5n)CtlpIy14WE)h9aLH7q5v2x(us8k2G9IL08ixRUI`4bk&Zc{jlrqiPvwLo$X zAXmO>Xf#j>lzQr6IxOe8S1c&bxmUYsLu{4wdS!Db9<$kmSc>nPXYMD_EPQ%Zi7I*ZW6eXvoVHTNHw{x@l}3BXlieabVvUg(?~SWx+dL(3bBvQsVLqG>1k_wG1|Vm~i)jF@pSt$_1VBrS45_Gjz$jeo4DA3%9o!~npTjqC zpyWBE;0FXR`)sFDOcW%ap2aZ)xQ(hu5({@?AeDUm1SuUM9lySEWDKAG>oTaMjYZxf zH?3Zs#qAge_SRbAchrc-r^yU*H+`DV8lS1YSDM$5F^yX^{{x)Q#ry8AwDGMMd6Dn; z_Wyh?-l{8%_yC66KviZ;yM4eKi<5c%_l7gF@^`8UlF9|4djP-*Vs0GSozwSrFfA+( zARbRAU`qr%Y};6Epo#;}T}EBa+v^hqbo|{X>E*S*4J2}<2)N=1a$KLbC28;GPv`cy z9PDPlJ%s6dTJB4Xbt8-DxvWg6<63lb5V~0u3Uvag49`iuiX(uSv^zV74C({Zn_gb< znUlU!@NRkTIV+>#e@Ea@AR})pa{~Ylp}rK(npVB^hY{lZxer9RdOWT+0Z5XJ+pwk7 zq`_&|Y49xJsQe2cSpUeqc76A+WqRZ6=hK-<2*%y*r0H3U8{qD1rpeaPdFo(#M>O&D zbWrPjM-JuPc*ou&U^U>Q1z2hVTW!jXe2%D0dMxtE%;3AvK_BaBufX912R{0c5u*bU2V&~n|~19$yDAr z`Q623^T*5G5aekB1pGV@;P$lY{ZwVKITj=rSRNNZIFoRr5mkeR0L*pklGz&}0-Kq$ zH?-)guJ@)i8Gpg>m1o%CIm2MU5V2P4a zzJz?_7;~7$1h-XH8+RD~Hc`BFq>QR)s6=%hSuEo_yAm9bBn~PX&bQRk4dUYD<+fHu zpup{V3|n#At&ps%;7zC6TDb;uzQfzhgYaP8lu$@{RlDwEg=j2Z>wcMCi}Bh(bAUn` z2U-9eO0Qa1J06@I&-)F*i*F`;a}61;i?}@hev3Iyb=>XzKnO8>eTF>%S63&w9nE`1 zZnSLmg=~!!n{Boe>%l{%0LHynEms8xr&Bv21gscip}Tztv=9Kh-kdmlRC zB&4TG(PYqMFEaXcd<>&PfIy}yBRTg@h=YRzU_J(=7gHY}JuP$EHhgzDz+Hob5;kkC z$S`oH@1Rn6ov}K74?wy%W92|TVv(xt!Adbo5|a=6XBBqy$=+JiGYNl-xgzeXBEfbDWU0*b)B7gQ*Nr_a-EgZ|1N)_lM=n@|4WT{nOSM#G>8Kxw=H)iBeW zNIu&cJwUPSRI|@?cs1p5NolvcBS=KA!U1X3AfVdS`I(pE~lP~9H; zSts>nX;p>x*+QBPR<75)6OT{qnjNh(ZKvW9`a|=jcZTcju=kq*m?hQKk{g{qGvykJ z8IF%AEO!e_%}1kSGj--u2W^CL*AEGrC8ja#9=77Cmf zQNM`LRO>W-G8u|gxLFShv<4Y?*$M$SMXiGSJ{Gi4g~mB?9u9*xu`rxfHyrLN^UT2~ z37d%NvF*jIEj;pB2K7s!Up85AafEu9_`+$pwsag(9Ui`P`Ho%%7aks7#_^D-2%FPi zp}2^B>*|S^3LmO+H49Q@?j~C5GPOnQxrPNW*kmHDVelHLRRtP~&YW(lw(}T^heg89 z1+E;2-;6`u_Zm$@4ba|n(QJf8n(LX z7e_g*7lAaXb^4PR6agvg?_qIDN~CE#_lDu!7*mu@a|Fx3dTW)IMdCeP6jmqybR^O4 zw=}_U0bW;8$t26rW&1%EgbiNbRZqE%v+Bufy9GgBW=}wYYuW&d@3ZEr;7#=K=cOil1DE4P z#&DE1=dQ;EvEODB+WSQ+x$t-_IbEzmhdwEMH$=M5C$6OoBh}3Mk%G5>1Z9LT(l=OI z_lijCrxjioDa>07rl>PAc(0EQ*!RvB%FQY6r}&OPULP%t-M5r&%+yWt!H`RQk96AlWTljN-_FA=JJAcC1(A zz7z&7@pAKaI}Yp(L;n)bc3qc&Y?$~dt$Q2&-16VjDatSL)sl7r8(zPfR*h+q*sl91 zMb{gmlV9qX17^&KTiyB4NDh|@Dt0NZGUbpv?EpI*%3BYr&yCD7WPq%w}eE}Xw zfA_U|+V5)mza7vX@6OEE+z%mh+Xl7X(1-$2{+PpOlugM&`UR@#<;@SAe3PN* zFm&i#__FeH_Q4gvF;ehCO+!DL*Llygg%Bp_Y!njcvNxI?O)9Hes?IVz37gh@u_YpL zPFHh~q+Y$}&w}m+m+LLjqxo-f#(iA56nGyf1a9xq2anae^35||VFaH3IWKOASu}hC z{|m3$pAzOqdOGXM-XWxf=dY;pn#%KuWNN=gl=J%wEpqS${i{_922+37yW!^bUsr#2 z8Qeb+!NKis2}6C2IWaQw1r_UhVjAajTp(driD6q7IqdNN;^6zrHMtqwd{BL`L_dgd zrsML@8+jCQjHLjfuTY~@e`zA69IWK8Cm7a<7~v9mp?mSmP&q9S{3VD*Oc$pru~P0F zzA|o^JV*`oCV6j;YOb0co7IYDSlcdjUGk|@jM5-R zUqw2}GCL5g`+*P}wPM#brZfH-q_h7AU7wqg$Rkx*uBdJ#mk zFrP`+(Ct)#!ls(bEn;bpHeoDsd!9%F57!eD1Y?+yPCemNRz$G-*eLXmJiIDZwhr;u zDJb;!g!qk;KCbLlRU44wR&a&M_LukwxMs;<(3RF;yYBDDW+2ceVowX*X5(1hGDu|O6 z2r7^m5X|OK2+;nys>5GPdvZn)v~(BgfrlA!2(sFNgjSRUKzmUHP(S$c`N z#Ebyzs=vYb6`Y(^qzzmmON`)YCVvb5Kp(|(u`q^khCk-i4$i3D42au^OC~X)(}Kdz zvffxYY`K-jI4=_HeY?WJQn73ZUb%v|)#8Khzy{#3(adrkw^teuHck$*Nd0Ld}sreOQL4=*e8d*C>HP|!3jJI zMi{H27XOl!0e^h>VKLX-Zt%H?QE#0!*yTEk4CtD&sBlCjk5DR<6=PPb+WUb!!{|+{ zv4!Ew+{Ul##9&VNDDmvN5ngRjVSN&<^UG4sCz0~-ub&G+)=q z*>m)PN$GD9@s}RN3ui9(`Pb~#O*4yXe8mS*0hPj1>q*UapLDwg(N^8FzpL)>U$ptZ z;U(Y+c2pqjz&y+kksaYp!!IgEK+m^J%ui32c;X47RT9WchsT>xu!ps?+-x}U00SUW z4|mxV8g4xA!bF?>cYld;SXP!u_#y#Mt-g?U#e!Bf>%h*rZgh#PjFWME2Q_n0J_0_E zk;FnSE`33A03QxL`WaZlye+Sbnk!*1^un?(&n|#Xo=OzkB+k+Kp0$HSs)6r)lU@Le ztCprEl>A=>=^HW3C6SIGmR~weHIVZWnBD-(&$e+=|nLfDh>$x zoTp7fif8DOSgTFpBg6XVON+L)g{$L73b|hOIzv3CwEptTd#p;j_)R%RQN~~V%(3ic zky6a`Wl}%Nq~cXd$$%LH7e2IadK8R~p7g2CT`qYpYl2!nxa_~iKi1=u;;q0hXh`kv zeO2+OYQ3foP76xeXK+)%QZkw7^q*Ht3K=dCK~)lT0L5s<)>}LTl_pQ9$j{^X$~AI5 zX>#aMR5obPGdy|i9+V5t%92)YIYR)XP%;Z{(fR0*YdMynGzuGm0#|quM+dXoPP(P5 z-=S%vjbU=fAH=UeD&?su>tTJ>kj)4d7$F(`h*l%K#-88i_xWwF5gPK<@|_2QB!QLEM8hIu38^xWh%wg+xq9)Y2;@0 zK9q#cUnD|ZH0yDGzdP%T7;a8+y?%}#2c6PlM8qng5=decThbgG2*U^}d ziM_fHg(L^u+kf(-#hcVvGjZ%jcRe}x<$oHwOv9lr|0M02=q#gh)cLZI>Z%6f({m2m zj=+kGq>Rw;r$I7$Bfl0ST8^~z%O!luSm}*BR*-lNLa@b5F2P51KKx^owy^t4XXzxdT#TNg<0@#GyR)Y{AP zG)81-F%3V(4;fgc#8rc)5EwrOrz%ae_4UJuG#jG{#@hzDt0|aC< zmGbMW0P}j%_)|w6)%D3hUH_ip0HXv{xYarbGz~RF9vLJGS@`{B0_A(y$9L&SH1WJF zP9n3OLjUloBV!s^smN7FGR>Q+^7uh^$TpIwu3+sHvcQn1Yv1G;KptYDb`=_9KeHoe z%jP3v7Ew-*B7zgvPlfVoUIPQWzggN(gR-iMRGon-KpN+nFKUxQE%d2_xZjyGxuc)p z{1B9g$8OnHklIT2+rYmKg_c+0qLk7l1c#xn$d~6+O!eBIcXJ)RvC*D7kcVLz7^vVR zoiJb;T{CcygpGJ7@eD#g2rq|x3M zxAu`wob88URJPe2+p_-iPxImj0_T{No1f9l*n~G8bBel(;oZW z&3F+Hu+<{cR(x3thbCy8)l2hZ(RFQ%*^`$9rf8sDRA0XtBd>&fSkTPj@(Z`l$O1wW zK}bU5u9=cnf{vY%9{tT2WV*VFh^qG1oJ(MaT9nEI!dDI6_acAg(V}ZB|CwvEiu_B7p;^$eX!=35kU}9! zSV(h2(Q*voIzf~qq=+Oz74s&9>>EN)Z%(F${j$broZz#@l*b6Q4R_g-_p#&zkL0Rl zQ29``f;KHOe)zl{#h7mv$`1!vw)pZN6FqMzH1xYy_a&i|c*F?R9vLAf=gVjmAXo;^ zi6l1GQI4-HpU|6)2DNKL(-0@YWOUf35iTF?XSEe9F+la)?HvK-nvq2X+j(`Pj`ANo zci97}w1ig%xq(uW{uW5TP?4p*+!p1Mib#`Xn^WWPxP%d5WDL<3@)_0V0!5Uu?)30J zqJdV5jzA^lb7<5EGPAr(6*-Ke@u$cMT7Tdm#T1mmXm7uQLI!wvSZ3UTpBqR0k-GA{u?-%L>w6GrKM;^K39~Edl z#PQXO7-S~YRb^w! zD41lr{_E|qVS~`Jr@%kA$QJ}iKq@lp@9p<76e+XKQj`=Ja42ck_6}HDpj{pxRI-?_ z^T#(>367?|hjrTQ(t)3CKMcgo)$=Ozb49AZnU?#{cNr+FSb??unG~>FgLiS1eo!jN zC4AF`R2mIsi>*dAm1|~A$FS(NOQMxzIf7Ys?y8Z&o3Fc8mG$}t*3I`}$8f-9$a6~3 z4=_Nw@vQwqmH>>Jw2z0H3!KNb^+R%ML+z(8%43)DH;wy#8Rz#%-NgyUT7$OIaU^ zg{1W*>^4X|FpjHI^s^A!2lEZnPB{2uIZHoUNHmfX$J7GFe?XAKrTFU&HitrLV1U6f z-;Bi8C%nN}LF_p%%}^EAeHBBs{k%^HW9!UCHH75bMu}cjh}BVXBXcB0<*EA)CeNDl z6uDbZF=FxTy*5088l>f4C1j-9yag9OX{5%aOGJ>{_Kx@u9xM1Jh|~Fj6&X6WSi;yn zXdu(=#uq`wAW9{Hbtkv>N*h8wXGF<}SBPhXKt_PgvN{na}YU{e&TC?~p znjP)%;*p|(sBC)cTyU>?uH(76hN?5ms%45Eh>k*l zi5z+>egi*4cljE}EnN25kFJWyEBxz3s}3w!6m=mwr%O&*?xSpYwTzRc?w@4Uv&2zn z+odNV;^U%(!4x?Z)vJ`z-|A98r=wP89HHf54s;^J#(Xl)UgL3X_Oka#!>QCT6`2^_ zEc+d)-hyTNg5$C98B|y!P%q;wrUs@LmH$$Y*L(=e1w9&M!rMVmm5}u(SjO9WGQ)`v zYV*|cPCe&4WU)#kC8bm1JvMKLW>KkN4GLNKk^kF6+5IEYzjCx}n9yFEwmml29W?#Y zEd-HzXoh1UPW)UYTfST9T88-ds9~za8p@1K3oq)C^Ng8Rk@+lS=5kd7iz5iux=Ajt zk%asoG@ijzUkwb1gIacqRfcJ zBx+NsdiQ^hO5Y(STb&Ixf6vLpnN0`#q{&Jtq|-~gzR&idO>-JL>FRlgMPfA>+{pV4 z{Ib(8ys#mMU!VCqpt;%_X zq_2pnv+tFfQaL9k9y2#!a**Rw7Ab4nG$V&JnmwB#dm!ijJ-Sb-p3MXdCtC$@OC zS)y-FrSkh}#~MYviiMiZhDv!)wqkwM%4zJy8PSgZLGuGbPm$fY#o6IACKCBo)*AKo z^@bbfapOx2?ilsFA+{o731^{Gy(L!wRE zRK$`C@cTxFHScevT+u*YeY}%ybf4Wcm361@9GP4KW`s|N|9q!$dC`4Leyu$@Q**bb zecoC0bLc;`Oqh~-hEi$xqe}JBmbFW9xocCH20VucNPp4ptH=C;VNf!DAOnW!>{G8W zNZ3rGO|5(o_Alh!N6`(fwZ!H8~=69NJlJMD+dA|)Cu6obi z0N8)u^y(@Qj8dXf+07vpcO`6X8IO)y^W3u64`%Y=zONd=T_4WjL!ApZtp|2Z-Xj6v zV#-*N?-3@f>s`P?odDO#L7U-7YB+pWj`e(n0-u z9w!I2ua;BGiwaaft?HE!d&EBYF)ieS5Ol!K<~WD#^JCa0Z?P&WWC#n;WD>>HIh54&Xa=$eO+N5)e}SX&iWITFCNZ4LBffX zUU!F4S%O}9Vj=(feqT*Kk$zvXrP-S>An9PHQxxPB<2&2vB5wVD64$b>eE#}0<~Y#+ zhK4#iI&xTZ1sgs$*YMmn=gZ!<>|NjjM_Gc0*GC>W^Pcd&b2GZfOh*xd)DMPCrigR$ zdNtvDjv*6YB^X}hP#k~%RRV`zevQ0*?r<4!%@L27rE?;sg3r6zJmAVJsV5sJ5fCg- zm~%KBSBln~cqq8AiJ};6FmB$*c(0*2wql>{d8e zIQ?t3>RpZqjN{k@I3Ix1oA=U=S)K}$9s7{G21yNsaDXz38oEA!mNAmu_Xlx?C!5)X z=LeV%AG?9sb-pQvk`J&PvHVBAPf>lcb=mQ&5hxFrJZk@k z@j1Mc_$NqEu2DUx=O$kl{&hz1W-#5Z2LgU}`aHdZs%pJ-%J3Y$yXX$+x_e}p1fb^( zr%P$g`UBVJgJ)tGDBrsko&4i8zMCwXlY2*#i@69U(B0|U1;EstKW)7A9K}`4&d&z| zo!775ciaD~r_Fde=ci_!S>=uMF}RxfwQG#22e?i#fa>|}daVBZP(yt_$v;-$H1)8h zb-#Y60OULoZ}*{SY#$rE(Lp`1yYHp?Z9WrcMzES7F>OvvOt|l7n$Fx&x38OL`+VBD z!w(Va@G{vMj7niOp|WeaY0?KvqJrdJt6Gk_zu?mhpUUVW!zpic(=$c9kI`w1{-kGR zioF6-K|sH%-OP3%ka%jH3ie=cU1poxlK=uaU72tVxz=CuV;z^jc1H^64{O2EwOGm8 z!W&B%OpU#j3>BGur)>CbV~<*aQA%YpI;p*yJeV<&6u95)b3d+ZssZcwIXqexzk_0G zxu?D#GMBKj*#H`n+s3FH8LdJzQ zI>_e*3Q+4cH5{*dBxlYvSQ8389+;fD zCIB^HvFutO{LZ&R=x6FZ*o)SEP<)@-R`Tyj#Xgvvme&>fi3NYWrPC>pO=jW&SmyKj zIlqC5F5&;e=$X&idw?8XY>T`(2Ahl}F&?b8us_}$A3v`u2<|@doOi>VL!PD9WBcCY zYG#rB$;`sbXu(?x0=HGx`+s#)4l6*iU{46qqEJAxz9%lWo(n0J+#lkxq>hM{vt}fK zr0)4`JSF#k|^cD zzXol&O1qT%OKlG7_jjB`ozVyKDIzRJkGb29jsW*f>6_cW8k`pjXWUFaH&I(#+cE*` z#fl!rrdKT4B!&T?CVLQ&&%pMt_4o_HTlNr;@L6MFW7BW%>x`eopcOu;$A`@&kH`Ii z{q5Eb>I;BPpEf(wK|XU`b}fP&I#vKgq*ngThlG}#ZuD+)zH=$wlo z;*eai5aUgzHVap`!&zKT&dY-HGhZ+I+ZB5PC_`Z0xS!{STmXZge_vEYCGhr)#Pd`^ z3$=fLzw@E}`7*Rx@foxOKr$qOgx*_!1(5K0Q9ly?2-chtdAkv;?R=w# z5N&nx`GtYY>w*K$&3U#1gtV)lPrAflR#LuWKejiVad{j*=q_?qb!F zv*FK50=yIyE^cdg$S zk_&sCY;1lWjD0Jc=CS&117thA3tczESw4^5kFM8ZANK@kUTBV3bZLQFD!WBlhhIM+ z>)!3z{O*54LY(}nm!O>Xd%TQgHtH}1Rvd85J8ZO}0Snv6>!U~*lR>NW13(K%+tUPQ z0LnQ&zP8!*ju$A(A0g@dCQ+zlv1{N}3--Woj&L6aFAI3$7hY!S%o7-g?J zm@l{QMxj6^2>m6*k~11rsj$5rk1%1wSgVQ=f>k^8@@tg`kYafKuBXt~*Z<*rXI1A1 z%%IU@G69G{(eiMW`+MZ4x9-=L6fgTM3uvP(e zXfEKX@h;CK%D%39OZRF#g~aR}n3GrPobO-Z*L#8WIpAe(1eeQ=($3CqwnU+8RpV-_ zdv1O{q(;0GF66o>5KhfG5w;>#9GR6d4~fz$o7JNii+#Lv_e19bf>Z0C5Ae1yVs`FR zvEzR_`GJjNc)-6%nAKv+#f6M#Hs&j{WBTO<`m?4%Y%``5<=EXy*;xF8%6ZN9jb(IJ z$F)H4?n?0{fVzAXSXdL%k8VavEq>+toA%!9U++La?$oqyO9Ky7KLhH9goqdV17nL$6a6wUrta3Yp8IlJofz4&|3i?^ysx`OW2k>$UAjt6lp72Y`#~ z`hR8%BwUtK(u%;VwUD35{YgE=+uxZLm&eV=!ioy`iL_wT*9~wA0YEJyckho-!tXEc zQ&F2hkF7g(HeMSO38S40zan{W@M%3G3IF|!CH&$-1&+KllQ#}v{${`x2_pRSE0Cw_ z^^bthVa}h76o{W}4frjSV{)9D}XRF$b^Yk%uAGu5P4zS0+xsS8FA1x0O|FmgBa# z^^Yz{#9a)$$G?7$nCs8KIiSG5 zfs7acoVbXCp5o_~`)g+$0Xz$r!;2G$x1Tm?>z$=r6h_a$Mr{aq$S|NXk6?tz8%evy z*zpC)sq0ibJMa%CI4u7VaiIfSkD;!=h{ZXPV7v6kErU1=6+uX&PLICKW_#D|?oXNz z`=NkrXXd%-j~yt|aXrt#`uhE2n0iW}*3uoX_}1jd+qech;<>;m#JU?mxq%!a`z>8g zXkfgb;n7XV#_Ry%6h${a*v&vMJc2If-}mVMBJWs*TUv^|jkv2-Bjb>%J92uqd7TLU=>y!WaV9j34Mo-hb))(Z)P>5&@cH-YSCal%A*;1nc{D zaE0Bjzu{?ihwba&JPaWBd1<4LXO~DPR>{W)3)1*WlLo?2g_vC_Db`tvdWk!a(*p=N1)IJMpOR15u+o~E!-9<5njtfNe9eon9ye##DmsqX>tB8dW<4Y^ z7D;ANG;c)T=`~coW;1g&qo^WIELO6zr1>R4FjJ17w^ltK60UB~{m8DaNg7Jul5j1u zOd!~gJtWvmf+V<{CRoy1BR^}7pN7)y5l?ToW zo1ERer?_IqkiM@@98E(g7}D-V;l0xCaWIdo*{C`jiV7|ewI4*q+lqC`eYXmPTO*0u z^pV7M&@OH}i?G#PF?5WzNhDQTQ(LjSpa!Xv>XR_U(h6!#SkLoR>fDD{bV+q9QtdZ@gy107Rk4b~Wib-;M|!t*dAz*dZ=*B4D} z5`vE#<;RRb()eSXmc|F19l{*HGtvqA<)t#u5eIG5kP(>*W9$nxDroDOGKfdSYuhFH zK-7Zbi0F3@m`r$vE*z0@Mes#FA(0=6%y+|w?({~*lI?m|)paE~5{Aibl!~I{nWgP+ z{<*=ZeW7pG56h|`wD7-c0@<;+1w96`^!ZgvrdGA0tc0g#0mnOL01bv!k!3>y-Vf_^ zz{AXyf`vPtpkV~)`s<@gBytN~heO1D$rW{fsT&5K?Jgp|@n|<9MPl#4T|Far$0hb5 zWE~&>>MSfYNdpP2h*d;Xao+TkTvHby`S0#g+KA@5 za)qOs$(cS*{tnSoNpCjV{G@_7{WHj2MFzTt867guv(jYO%Am?NX?9X$>{m zd+L?ENjp&tBg3JB7kq_+*!Sx71@=y~>WH?yEV?%4-bSHqR3Pps=H$wOjMf^}Wp9m( z7RrfoGmBQ*aYx99Enl1{QqE>MrKBRM#cUP9o2IA_SCE^qVCBNDU=x0>3B^A0GzI3C zZsAQn>m%MW2;0!nmyv#;FNKz}L|!-h)A)|O9tD2nI_8QBDh!%BvRke}oMlo0?E^YY z`G5fBmewX}TBwvJ!Yx`}c+?pAf6)lP7K=K?C_3bsohq(5QQqXGq`-nno}6=%S!E=# zLG3D0@XK+!VNA6NFVQTCJSiOzQp#yRFqHjrrqUOG;I!kfr`PDs_*|i>e;U{rDOFCd zH)Wa5RJaTQKJYKM1HU?v8dd~BeEKlt8UEZ(-M^>Qa@s2+cL{~cAub$2?DBC6<3yIJ zAJ8mfnjCvqp*G#+h4bczO;%}=_Iu95%1kPxhkDs7M5!S|kc#z2nMa#Ji zi*YNI)1pj;Xin{8R;?i*$IP71WKGDtLj3-Wko{FN2G-@_VG;J1j>VOf$46S;1(GxWi*ccCL)hAxR zlZ|c$jg#{ba7UL1QG4GH(-R>icQ}GyAD@gY6V{i8LTIg7PmT0L(Q)Lzj-xd@#G2x- zmZQ8j^6k>ez)w}T{S!&@ue@xghu2V|q`nUx_AC<1xa97<@!Wi923fgJ{U@KDg@xHj zo2%hoY1k9u;Tn1tIe?goQEO7>Ees?PZEoEq0e;VpL$UKn9!KU#1!(jy>uH)ObHv?+ z6B9N{HvLvou1)JunL~~%I0_h(wtG=Y3%Uq}W3u1}%>)Dm7)8E@_5+0JhDhkICwh5N zs>!*KXY-)1AmoMgv%A%R(X(L%VB3MCO;NszwSvo{C+c}u;n+w%cB6%rSK7dCni50J z;Jks`d)t*IbDIQ?KjmAT!m}f?mMO{fd-E6tHOoV%CUFA_$P-zDNcFxwJmCl_=Eb`x zOhEa2;4o~bkJ39Ia-j<2rX>fp#GI}e%^~L}b`TIpD{K9Mq}~@@xw)HZ-in)gmdS5XQJ#b}|_ZlrqSA>op(v z3gbGOh$HUkta(VClQQBSm3^d!OBREy-Pp}P`)^{DNi zbj?&QSdb_P6aZvnQZ{0g1t`}d?Dcp#^*`@dBy^a;^S$`MA`y!hH@%P z8zQahVq4+~^;c{)@L06T(e#KdKkK(giuXQ@oF+#!ZI8*9I%V%`!Aqe)iY$!%N~%e& zN$W&dEF_a0`ceC2R3AT#YWgS|Dw(kWMvY&dm9FeU{zLv$1>OX|`~|)5V$Ir0RMZ%9 zyfaGYf}c4}K83nj4uNfpf+XwYh@vHT{(`;;uA(+~7Qeltx@g z5QnoEL3M-VBB&aA>}Md)_%zVv%=2Fo>27YRrEe}f4E=c~Q?Lgajt#d^Kj;%>0cHc1bqk*=$-Fru|ZE??UY94bma=kt3_)ttzJzOosk z<`*t;{VM&4S{)GrE1s=k)xT|rx5UQkGK?smbfas~=t5)Y)=LF4PtX}0=?%6G3bj7>o& zy<5NH^)Z*Dd2vqbg77WCbyj*{fE&+Cb}YPH$--`TFS(WV&khw%dDm0@w=qmJIG(sw z7@bWU(c-qrVQocPr{<^>4)hW;*v}7?pg&QbN|sIK^NDoVf4Okw9_CA|rLgAobWAfu z6HAf}mMsvzYst2F3B8RD@U;SWJcZay9B_Tj=tPZ$DV6)G&y;5_`E-q*gg$k3WHRXW zN!dZbt}@=@dyaqT4^u@PJ6Tr>>c-+l2{}J>nC~B*^yrjZGKjf{%xfzD$cBW%>-6sn z&%D7{ob4O_Dh>RP6Zls?*33Cg#w&_79g0k`$m&8&649Pq^JGy$od?`HhN-{gGNNZ( za-_!&XsBro_t0A(RQD$d4~~`7NQQO$1|)f{)VuV|WxfN=2TXiYPU6WjG{3VTq5j) zNzHJcp7dKo6jDi|r$+EY8*YK##yxt0@CVMI@(!2SmYYv$ZZKwsk&m{u4#=}g(Up6uUWmsxmpf8Slqru?rsGFS;TXz=so+45qU0gNzg!y^M>L}4 zgpJNQ`7=0};e8k}9R_KfEX^Cq6j<1%U!bv8xA2b6Y}+NYKTXkZuXvS98|pH4Y-beV~{fQ-V6!)bnWJ zo?Qee;y1(-S@Vo!$Md9{EZQ}Rp@ouw)e3kX3hQXvus%K3DrP70b2GGu-eyFU@~^O( zYuBR%)#PUM$tg~<-|fnE)uZa2@nLO`<^iZ7-0~Ai9xGd-yGAf2UETO=aZ!i+2?t&X zJOp!BraV$uB`s$OU!UlpAEKQ5dLUC_t-hu+zO`|!;tab2e+>ln@tp$!I6RUwvzZD* z6tu4y(QWfh8$smSRg9TCapBd5-|f&+!mVvccf|Rp@S}Kh9I&R?<4x=&%1GM=DAtc8 z`#a_^hsF~P-IGcS5P;WNgwj;RfoG)p#*0WBsW!9iVN8@&yw6^~XBunm6JKu?pZXQ% zF)l2K)vaC0qmiph%7pBkkxwkIih#xSF0woB=jRzFy0P|jph?Op`4H4d?_Mf-+fbL^ zY{a=M#aGKmtkAU9jZ))NsY1F!HMA)REZE4Q3 z_U)6KKn)GOD^VxOMD7DWe<|r~v`r<`1@L>`bB8gvMmn`nHGpmpp1;LQEjl7IVG`GU z{*TW*Ba>M%M3Fr&HIFWx;KBjb6p7IZbHOjGFzbm(nc4N+j4!Jh9sFw{cjyXMH*wfo zpv7#z#W&VTmZ76i5|B+tm#per#Io&2MpY^g3jY<#vFD6sImygAAUEz)Q*IbU=4@!x zR*-ztfERs9)A5t>yHqmOwi~eiI@#M4T#LFb41$3KH7(5zE*v<+afjOWF9Bs(l4X?i zRBZGhMXGMq7&lYoS$Js<-_6B#kKd>{cnBypgK7z7>&#O*7+T?dgq0}swh1|jVLXaW zqDR_!Dy@YK)>Ndwzhv~_5r5Qm!TA1<-*v3IFLBIw=a)lMoK-7`T93g9TWx`oZb7As zu@Ii_zqyV)*{88k^fS~~SuQ5Dnw_5`(C%YKB~q$^?@5 zQ6*rS6%#FRF340Uw=AzC;`}el{dI6)4}UBC)LR^gO-&rC7LYXel&A%K>I|=SIvM{b zqVmr~G)FE5k|pjHv0qN(t}t`S`L;*60xuZ&iL$Wx5TgpGLOB02ky6PwLD_T}ai#NSwf0YD<{YPdWn&p=OZ(!UNmu_HiO7ENy0AujGX8qUIU6*8_a zOa?zg57t=7%0bJyBU(ygf)sdGC|7oC0X6Kj+XyxUou$s~Otu(n>ZK1NJ^C@f?2cE} z>R6iDwZM+E3G+Ii3P1X%;b}jTELRttSxNf+OKzC^p3TifUP<&oGeYPp$spm9!Ppa8~o+y;~|1u3J${`NCAVmm{ZKo7wG zHIRgqgoap(O7&|b-(6-L;UbAA2X2H& zr zQ0-%rGVu6y^Qr2SWPj0zPLXMtg&4P`Y}vnqJD6GiGa78nJ`yz&WwNa+@d2H~NF9n+ z*w=VFw8IWu*fv%JCK8+mP)euMbR2JIZpugHT~pE93VG}ZbG}RhCwd0EY~yV?UEAnW z-#|G>u0t}AA)5Et;jDF=I;zz%VfIkf2M!!;rEb_B0>SLhjd_SHXf+DyXGx*vB+OmB zZtMQYOXm2_UuYyVJ*yRMn6=PLfeY~Bz2GNe`WHeF7LC!3tuaGb|RNh4aW1`a+% zVxKg|;7mQKqApva<4-@GCPR6JU}C&v)=0TtG+OTJI@;TxjDY!wgl899@dlePIyo^; z>18GC&P{Cf=q&Uha&9CXA@@BusWgKU=O$pk+pMGHhysGGU86vJM0S^3JFp@f{T#~` zt)U>Q23DB7H{J*=3?(F>ttOucE_1z{YdyhPN=+JJI4x-%BC?dNplD=&IF4vb+<0w! z0hU;W0>3PFfuT((FX19$GW6hXlyu~W@0nPd@H_?-2gz=J5>b=!&SW5MNxtAVNheA? z0mq2BUq(Equ`hd7@G5Q4+>Q4i{yXC0z?ZJ%-%x60V)d(0`TEoAcy47?-40g1Z&vlz_!?UnStvF$1NtmmdPZYS#w?yHzSu1zX z!1Q@AhM2d5b1Dfa?;Shi1LQ4;`s4@`jSPw3KE#nHnxrhR?|X{P!K-> zz6b^aI8MES5Vd3b7oBGkQ545d&VQT@6zU`UTr~fE8XzG7(@=K~EjZRWo*%#kb&UG= z{6QXX@$M`lT}Oc37d}w+o5MCe2>9x{z9RGNk5W)u+@Gj{1!*?VGa>!lqa!QE_nmDY zz+LX9wf9x%@ecrCKdBZ>MJYmYB}RS)X>GbaWK}q!VZ|XI%RzC@_YX%i5Y<{`FlHNa zgwogPlexB~^*GS%8x#_mqhc<_R^<%oVpZXOz~#XUaT9=BfDzZ~dLQjPfNc*W-X$lvB{sZC!A89AGdhZ{~m5{xJfDC`9M@4WrRY zD3l^T-C>fT=bOM)7Y9&^7}q$H83M=wm7sJQpwwz^-{6bO*SID<5ODkeF&`lr^_{ch zoPGDLN>37cj4lTEzE6UY%V?l=Qlb;-(Z@HInrGM+=tV)T7Hae@D9=XnW6$rf$u?=4 zS)>mRDxA*84AKT^HnCyFi$^{U?1~hge>3fwIqUKLW(Hxy<_i9*Zi)BHByQ;3cT3KLqzElQfl@KrdcIWl8xpO0nh<`HZ)N;dRpw4wP?bGmr$~%^h$=x>ZehkUZ$FkfQy~fCsUkz+6`0^Au&^uui6L)ZJ8Tiv4> zfSLf74;DWh*STN*&cs{%z5Ud+J>at==Um1QBM%Ww9v5w_@A!;loedGeck=o50G|j9;2qL!#{vK z4j^bjV@F5WU9Z#Z5Tf5uh*-UT+bHI}Q66>i<8*tk!H>Kj*Xs*_DaAP{9P|FL*LsK5 zPQa$U6L1knMosKi|PU`XAeLlf^T_MNU#V_);q@LurPgNthJO+)YetC*NQ?|^F)K<*7UGy6ZPjS8}U43Nz z$v@zPu}E!}v%YU28;R}+W992u0$P@wYJfX2=L}em)DZr}ja{Ry%2T%xb!p{ywfSUxg$?G`TPWC?7@$*&>YLExFA8}Ew^6c>7FPhVb2uLF^03r942A*cWJpE;zXg=g4=96>AAiRaICV*l zbJ!io-d}Bh0{kY1fI4ke1A}OqJU{%_O)naVe2rseFhjT7b)=#4S6KQ=00B_OzkDR| z`*j}34+fHzqQQeiMC1=l`yo1v8r!WA1%f0r;WIGvH?mq^qem-Gd!jLXf;;GJH%b`( zc+0B5EY%~FB@tb)W0Tz?*g1N)RSg=s0;S3zO+Z{!Dl}nLin*siS@Chdxna~4WA#%> zL&H`m(ulitYe`z6BJW4?)}kSo6rO$qM%Z40OdRt~iSh08S(K0QI6xpA+Ftu}a9ug(~6 zunQD04R(0gpiY;(RiB@K+yRYk_X7lfy-Q!A;lw4tp;D9FFnL^V;MGmP(tr^NV1}P9 z8i@mg*WYn3HamI1F3o`R1grBF6enkOKVbI2YBgz7t>27~i1UhwK@$W$@VJFY#P2Z( z#CWOG#Q$YySitlp7K1Pt1O`(&fWJmmN3D_T^rsmZ3#8zeZIRvA$F%)#)_IXZum7MR zmV^J`c@c4<0uC(bDXX2{&h~-8ddYiQj4@ZHLNb}oh7s@IaR7B|B?V-aDbyk>hDid| zXiMz$h^kILaW;wmENSoQ>tZ^-s>$!cH-89F>+?oZ(0i{2>CtWz_75##Tr(T%vCh8kk_Ed`(OOao@ZlD zF0S1R@6_F$P?W*H$9d6U-f1%eWeC828Fb}L=-?KH6jDvEfSI_q6NSF|HJbwmyqW@_ z-fr|%R{dvJKc1!4Mw8*l?N^llFPd`odU&!}fdOt@0FK+UH+e?rxi9f^*zO^M_;DZ0 zQ-9S{j=(-(MG?QZ9Q?*d?EgD* zaRs=pQw`qDV=BB4s#36r3IS(EJlf_O{UV*KaNkQWFzX8-tYE+=~Cb zW=5gjmOtEGrZ2)I06NZ5?Jx|$jPFUn(W^t`SY z9w$4P80GC&jQ4z!qcF$*s_XpHhUj3v#5o@rtubs}JwEV2P+tQXn8nE~nUD9Xe+Du1 z0>EYyICo(E2g*DWg$K&S4ldUiusOd$ZFsJ5g7brbhpKO;{gBW5UGBl4$1Rh%r7D8@ z1?6rC0L6SH1(RlVfe#E%Tfj{uz$E>{beBBfFoDJ1GnTlU1?4%uu1Mk=%E@Zu$DY4a z1I15(8P8}BtH@M5756P#kO+1W-dqofvaY$a=lsF7Lt zk87;faJV5RHc7Ld5Ei0voe{@)fXD%PB;4UofO#98qh>EVYh%YoB2{uZts=(9`SxEX zltj;6(&roD!iw+=gd@9i?KLFd|1pfuOakl%^uNeRHs7H~44wc!(2sqP7${>U#$XMxi_pJcuI*-xq1fm&cAwPj zXW>=a0B|Z4^&kZ$N0h#bAevons;G73oMAa@Sr1yPWF5>(I)`sW&LV7A&*HU2!|x^Q zuTrnkl=^@se=Zj3g}8vij`~OuR~tDB@`2{;JY2hl&*c^qz8k3t@RDAt`|xBSd59lo zQ_m))D><}Y?}Rqh>zoH*ElA~8Ea5r8L;hE`^rZd@k*yJt;{jIn+!BM5llJ!w#J^K= zM!;UG87i8Iaq{3(_%;BrZCwjg5s{x!c(|{pfs}Bo^QGVez1WYih~sdb8)TiKsysL< zNO{!1^M9nuJ43C$9C=mZDQXy)gwKoXtmkJxY33ra!q2!^6b=5XX673kYsb$oJ+Skb z^_FR6P~#o3Y+nYA_j5ayA3Y>t0DC1KegMyefB>#R$n-FdlW7OeuTT#MFJChxr)SWX z#?}!`iOZgNNwHQ?toZ&+SY}DjsQk8#w?*sE{u{9A4quoj^o~ZED$rv!*XMVeK-E(6~B%KaLh{s(1r~=HDvX(thK1U#V!k{ z-Ngp0)Cy01Zhv|-m6`O&GV4i31tZ{5B+n_2k3msRZPW_owxrZFd#2$Rf7*FHcIKnC zTm2Gk!$yk4qI>!hx#4r5vHQ{&aK#uKf$LuY!p1~UpgXaR*q(Zb^+(NvR;tj~A_c93 zQuYyBtRnd}7xChnZh!C_P|?2C<~2$sD}<;&E~-n>66_gws#>CR1>K!4vYN zvSua|;}^5*-Ap>70C9uXjKYnS+7!i3B)fFrG&3=CjEOP494qW6b=V0GN-5cy?4_2P zY+$$fzyXQ1AN8(tD)Zm)xi^P{@ zP(J`}4{+tl@$ILHo11l8?Ml(E2Y%#UeQrvZj4rcG$iOLS;{715;jxF#lv6N^xe|)7 zwrE`aLL=THzi2K#$P?jn*Dg6 zDH;d4#3;GhaN$3yHJ9p)LQ0Wm9|$f2vMQQFzTnVIWRUW5l^H6|avF(xu!9a-? zQ57yxFx?|LWb~Cf%w?)r=J17ard*nU#*<2LJP7iU8AK~){^Lc6?`&vA$n1z54^8zH zlSF2|=TU0I_8PC3N*$F{&60Ig|hb$R(7q7EX zruJgT)Ntc~(Xoue?^~TFl@}d-+@2-uZakC|+i7WSeK#&%ezm(jk zbvJm`8EjP?MPPUxfa+Q+qtR>89-2eg7Z5ce4g9J?>^67i+Y)3h=!!XS|3BPkLJ zQrwRy11fao%XP8B1!Is1adGS0D$Of+lH27Gx)F*h|P%9IF0pywT9Ka2TKuG+ZC?cq@uC&Q0RU@TNpr0MzbRKH<9 zJh?n(!)aE&s&2)p8@6vpnzw*nQa=bfpO*Z_TH83lH{!yJZ8#K1o?lZ@pc1?@R)cnKHrF zt)PxuOVpmYsHDn2wKZ!Z^x(E|B-J~cq{uM@s6Ya&sStI$yhKADHue>+QD4ZSbM&Ok zp3)G^=&}UHoQLA4xv#xdT3^cWEq8bH0^`a_5vU4qmonZ*sf<_UQB%lIy@?(PzY{k{ zs+3eAJY!&gp!P5uHt8*zKvz*hSBPTiyk<3#sI=n6uW~OP&Ced^;aFq?Pq3v z{@YaZ(G!bFm}4=OyulA@jyYewnv7AHHnyZmoq!JQ0@bx}lHV4)^ziF5)BQ-ZZUFyh z@AWneP4^R9Eyfyl;2bm00?l1;Y>)0#<~-ezF%MsJi{zWsVs8Ta9vd2-)EPz%@sRvi za~F4a4%{lpVLG9qX;2tFYdE4Etnl7;f; zIH=FmUmw`<<^yPSOU2|-dDh6R3~F8!10y0-JH0f9JVSU%GF}e~%y-1Zo%`RKgU!t?=I2ZZmbT#n`n%7})BX=wzkdwG z;kKQduE2uN{b4>_^5X(E)D-wSHMf(yJ>QNQB`3nz({J!eZ2m4%3i@fu6uGqtwkyiJ7aI_C(WR#6JZ z4D})kQL`8}8_5;w(>*7|2WM4(2Xv9Wey?H_4aW@R8-3fUIE^nXxgJ33EhdA%ZccGPR8BJu1s>h0WsC~l4*xG{)$@KOMPmKAO9WMhotO0}( z^H5#R9k_7%_z&Tz{cW0Q)` z2-eO}&^d=^@cy;oY~?KS^+(LiY6VVNAFsDMukuSuT(;&nLQ+$$nwrAu2)n|I!dt)G zVFR`Mw6$wYed`ksI--6g2L|FPJz~HoT#9HxQV}+r)0OeHg-trvXYiWyo~ZDyK-T$wLFcza5fy%r^! z4C5*Axi8=H3iHY$smq@-cd!3kLof3MwVJ`ALX#OuKh`{{3KBG!8R8Ro=FsInJLu@` zq(L?5DxnijLrF%GmnyoE1V_b=@nTxMjT`?D{kTXs`GApEHi$4flP~?6?SQser)NRD zXz-1axsN)`L5G~iD{i%6RjMy895Qc5O{W?m=Dqs-+HCQA@xaCd5*rR{iGrurs(^2HV)vcG+RYu<_ z@ehdB;rA)ULY#7W^wdcyB$JDXrQH(#85bU zufFmd(`Mo#iOpBLunIEYLWF~B(wZp_i%#}(p}Nd@hPg0@lGBk}Caw}VE=8Zr2}Fz` zJn{I9*{HFfKSN5$_mZP@BWyDx1g60cr@p(-K)nSWMlAR=F!W}GRsf-@p~0Y)Zy>SR z{7rM`hRJIKUppmm2>ECpiBN*i05_oWs?Dh!P@c^*h^+|5ZZj5?3~)hDHZmrG8Tv8u zW3oTdm*g`uIIEsNI^;xN7&uqXJs_$*)T{WumEc5o{*J+CckCxT8;^T1NN#uf*3Gt1 zlfbY&>oQvqJtw~QlONv~t2{)rW|C52K{@EZ(O&RpE=qT)IzQY!3C@zX?V)n_Oh~M? z`|R;6f{@H&>0ZFyvs4A$>jB*Y%Hd1jw|qFo)BWaH?Cc8$H;>PAUT@lJRNOMr)|v>X zDuvm_zYQG5_Fa-9F_-0IFf>!;=kZYn{{GM$UrwTgtlk=*S48vEN@ObFvsRYpRA3la zM{Lb4t{tP74kgI8A&NcLax7ddlLjXm9&J+j^umyOU&jA%=!HQ+R8>SGIj>e1ga?$M z6^7LRv;b*)Xxa8(*!p^4PaSrQWVkw6e$Uit8T@lH!r#$=81X5RFX{FPq$V|8>Od!! zoLlIhIa}X%I!n4z_!gA$i!2I0aWZq-s{-#}`S72?{;SV!EATrWf$>LO=oyhSH#)^i_ssbAR!V zryr%I!{(+B^%295iIr>Bqmm&v>IxYFGhXE?3YKSv?qg{$Yhg%9i&os4aZ;PNfay!0#Xn<`(HD~^a>OGZGrn#WZul)^`OAiNq*(lS=>(6>NF@_FZ zssoAWnXhjGvsi*O%WmsWVL1BFW9L?moS}Zj^&qtf(~nX%Hx;ndbn1nMzmh{84(g+( zpIV(zOH1Xb%U|VD&Hwe#(DRJMQ>L7;8D^Bo$yrwTwjJ_%_6;43mj|TOeKCPIl+E&} zQwsDA!c#Y@O&inlCDEM~lflT?<;je$sK!dS9*uqn}mrlHVzF?s1Di(6yg`l zs`*Ibi!)H)g#yzjgb}CB1UN=$c{Ci^(z7!oDKM-EL&o_WpcbX+%`)?^b$S0C^xG0* zpR1--EU9r3`Yw?hKA=i!t9y`sA5D(fEw#`%yMC-z;i%lUv3id44|q@tjI6M<%N&fV z+_6sk1>-%Et{@~x_foGfpXD=xUNemL%opPBWjB-t0z~)#&0EAhNx*RJD5dfflPtATLXp8o>Um z2uE9urqoECOrUW@E!o_U!9pw2pj69XK37I6q5h~p=TOwq7HJ%$W26mHGPYuQ-tyW{-iMPOZcPg#N`b3`Bp&v4Hm+)4MNydzOT z;XGQMIKcjX;e7mxQZ51NC{L@hwLr575}3KkzlSmZq7s#`vrCr<<1ZD<2$*eQENp>{ zc$MT=;Y`L~j{XOyUJ(O>j$C}!Cd1oGAc^&T3>7fT3}OT98w|S?ic4)B+}7a=i?3BR zu~7ysTnB65RW9JJ^+@RVIB6qYrF`TlR|vf|sHoO|o3PLe4bQPC?c}X8pBM*aEw(SXh_$M^ltCTBM5?7dp5tSWl_j4jZ?mWAUK zVWG4&*fY7#sN-?f$nUH8yL0`}x5ucHAhK-^hj+sVGyuwrad+-3dNlb;!LS*p?!Hyy z^Sh711+z^iIQEJ0_f>NN-zW9@otAug*>+C0JZ6f$ByDFjuFXBeRv~-BZ>xz8bFzlK z=7h66w0r&h>OZaMmd5WotVi4rsEf?I2$<8{B{??ij5T~eFjS5~+11;faW57lQn1Nu zq7jbo>mxvZprFm(c9c_t>4jEsGjgH)KxytVSaYB<`KqiZ@L_*UK~L@FMIO7EtLVAr~?wOzB)%2lIF;>)v6#s=l}eWjL0 z3p8U=@w_en>Gk2XHMND)p8)BQujY5Pb&JE52C-)7PkPVZv9MlkH3SV+ zt}X#!G@NFDx6lj#8h@x2@D18IW^Xn}fH%iyyPBtni?9^N5n1!Xx zS2li=W7;T)bM8+678q6IDd0i=IaptrE^lCOMK8lyQH)w@g@==7-xMQl#=T3|q=1&I zR-Gv#Z=P6^%B}G6%a-i9C5GgZ#{J659?JL94Nlulqc|10)mq+8HqN31CjJ5n|A=~- z2PB>Ru8)_>x|k)d+FXHJMnr(ST+SNG$znZ)-N0QmV7UJ63SY|E&;uu~=SdCX=M9hW z*G8Eu)WH`5j!y#6ZC84V_;3(1z?BK{gHgRi5ev?m41eh^1OEVbB({3jkL+5^3ekI` zUIu!he}F&Sq&$S*Uwqnk9k_3tdoIcNxoeh*tm`wv)^<=YzL93>voB;k@31qmTaYnqrb8w-4;s#J<15du&Ar}z$yC8?4O^%aJU8#qCqgh?0+@my QAb?+TQp%FG;-(@01Le~>82|tP literal 0 HcmV?d00001 diff --git a/docs/integrations/README.md b/docs/integrations/README.md index e444757a49..54e7d7feea 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -1,12 +1,32 @@ +--- +layout: + width: default + title: + visible: true + description: + visible: true + tableOfContents: + visible: false + outline: + visible: true + pagination: + visible: true + metadata: + visible: true +--- + # AutoGPT Blocks Overview AutoGPT uses a modular approach with various "blocks" to handle different tasks. These blocks are the building blocks of AutoGPT workflows, allowing users to create complex automations by combining simple, specialized components. -!!! info "Creating Your Own Blocks" - Want to create your own custom blocks? Check out our guides: - - - [Build your own Blocks](https://docs.agpt.co/platform/new_blocks/) - Step-by-step tutorial with examples - - [Block SDK Guide](https://docs.agpt.co/platform/block-sdk-guide/) - Advanced SDK patterns with OAuth, webhooks, and provider configuration +{% hint style="info" %} +**Creating Your Own Blocks** + +Want to create your own custom blocks? Check out our guides: + +* [Build your own Blocks](https://docs.agpt.co/platform/new_blocks/) - Step-by-step tutorial with examples +* [Block SDK Guide](https://docs.agpt.co/platform/block-sdk-guide/) - Advanced SDK patterns with OAuth, webhooks, and provider configuration +{% endhint %} Below is a comprehensive list of all available blocks, categorized by their primary function. Click on any block name to view its detailed documentation. @@ -14,546 +34,546 @@ Below is a comprehensive list of all available blocks, categorized by their prim | Block Name | Description | |------------|-------------| -| [Add Memory](basic.md#add-memory) | Add new memories to Mem0 with user segmentation | -| [Add To Dictionary](basic.md#add-to-dictionary) | Adds a new key-value pair to a dictionary | -| [Add To Library From Store](system/library_operations.md#add-to-library-from-store) | Add an agent from the store to your personal library | -| [Add To List](basic.md#add-to-list) | Adds a new entry to a list | -| [Agent Date Input](basic.md#agent-date-input) | Block for date input | -| [Agent Dropdown Input](basic.md#agent-dropdown-input) | Block for dropdown text selection | -| [Agent File Input](basic.md#agent-file-input) | Block for file upload input (string path for example) | -| [Agent Google Drive File Input](basic.md#agent-google-drive-file-input) | Block for selecting a file from Google Drive | -| [Agent Input](basic.md#agent-input) | A block that accepts and processes user input values within a workflow, supporting various input types and validation | -| [Agent Long Text Input](basic.md#agent-long-text-input) | Block for long text input (multi-line) | -| [Agent Number Input](basic.md#agent-number-input) | Block for number input | -| [Agent Output](basic.md#agent-output) | A block that records and formats workflow results for display to users, with optional Jinja2 template formatting support | -| [Agent Short Text Input](basic.md#agent-short-text-input) | Block for short text input (single-line) | -| [Agent Table Input](basic.md#agent-table-input) | Block for table data input with customizable headers | -| [Agent Time Input](basic.md#agent-time-input) | Block for time input | -| [Agent Toggle Input](basic.md#agent-toggle-input) | Block for boolean toggle input | -| [Block Installation](basic.md#block-installation) | Given a code string, this block allows the verification and installation of a block code into the system | -| [Concatenate Lists](basic.md#concatenate-lists) | Concatenates multiple lists into a single list | -| [Dictionary Is Empty](basic.md#dictionary-is-empty) | Checks if a dictionary is empty | -| [File Store](basic.md#file-store) | Stores the input file in the temporary directory | -| [Find In Dictionary](basic.md#find-in-dictionary) | A block that looks up a value in a dictionary, list, or object by key or index and returns the corresponding value | -| [Find In List](basic.md#find-in-list) | Finds the index of the value in the list | -| [Get All Memories](basic.md#get-all-memories) | Retrieve all memories from Mem0 with optional conversation filtering | -| [Get Latest Memory](basic.md#get-latest-memory) | Retrieve the latest memory from Mem0 with optional key filtering | -| [Get List Item](basic.md#get-list-item) | Returns the element at the given index | -| [Get Store Agent Details](system/store_operations.md#get-store-agent-details) | Get detailed information about an agent from the store | -| [Get Weather Information](basic.md#get-weather-information) | Retrieves weather information for a specified location using OpenWeatherMap API | -| [Human In The Loop](basic.md#human-in-the-loop) | Pause execution and wait for human approval or modification of data | -| [Linear Search Issues](linear/issues.md#linear-search-issues) | Searches for issues on Linear | -| [List Is Empty](basic.md#list-is-empty) | Checks if a list is empty | -| [List Library Agents](system/library_operations.md#list-library-agents) | List all agents in your personal library | -| [Note](basic.md#note) | A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes | -| [Print To Console](basic.md#print-to-console) | A debugging block that outputs text to the console for monitoring and troubleshooting workflow execution | -| [Remove From Dictionary](basic.md#remove-from-dictionary) | Removes a key-value pair from a dictionary | -| [Remove From List](basic.md#remove-from-list) | Removes an item from a list by value or index | -| [Replace Dictionary Value](basic.md#replace-dictionary-value) | Replaces the value for a specified key in a dictionary | -| [Replace List Item](basic.md#replace-list-item) | Replaces an item at the specified index | -| [Reverse List Order](basic.md#reverse-list-order) | Reverses the order of elements in a list | -| [Search Memory](basic.md#search-memory) | Search memories in Mem0 by user | -| [Search Store Agents](system/store_operations.md#search-store-agents) | Search for agents in the store | -| [Slant3D Cancel Order](slant3d/order.md#slant3d-cancel-order) | Cancel an existing order | -| [Slant3D Create Order](slant3d/order.md#slant3d-create-order) | Create a new print order | -| [Slant3D Estimate Order](slant3d/order.md#slant3d-estimate-order) | Get order cost estimate | -| [Slant3D Estimate Shipping](slant3d/order.md#slant3d-estimate-shipping) | Get shipping cost estimate | -| [Slant3D Filament](slant3d/filament.md#slant3d-filament) | Get list of available filaments | -| [Slant3D Get Orders](slant3d/order.md#slant3d-get-orders) | Get all orders for the account | -| [Slant3D Slicer](slant3d/slicing.md#slant3d-slicer) | Slice a 3D model file and get pricing information | -| [Slant3D Tracking](slant3d/order.md#slant3d-tracking) | Track order status and shipping | -| [Store Value](basic.md#store-value) | A basic block that stores and forwards a value throughout workflows, allowing it to be reused without changes across multiple blocks | -| [Universal Type Converter](basic.md#universal-type-converter) | This block is used to convert a value to a universal type | -| [XML Parser](basic.md#xml-parser) | Parses XML using gravitasml to tokenize and coverts it to dict | +| [Add Memory](block-integrations/basic.md#add-memory) | Add new memories to Mem0 with user segmentation | +| [Add To Dictionary](block-integrations/basic.md#add-to-dictionary) | Adds a new key-value pair to a dictionary | +| [Add To Library From Store](block-integrations/system/library_operations.md#add-to-library-from-store) | Add an agent from the store to your personal library | +| [Add To List](block-integrations/basic.md#add-to-list) | Adds a new entry to a list | +| [Agent Date Input](block-integrations/basic.md#agent-date-input) | Block for date input | +| [Agent Dropdown Input](block-integrations/basic.md#agent-dropdown-input) | Block for dropdown text selection | +| [Agent File Input](block-integrations/basic.md#agent-file-input) | Block for file upload input (string path for example) | +| [Agent Google Drive File Input](block-integrations/basic.md#agent-google-drive-file-input) | Block for selecting a file from Google Drive | +| [Agent Input](block-integrations/basic.md#agent-input) | A block that accepts and processes user input values within a workflow, supporting various input types and validation | +| [Agent Long Text Input](block-integrations/basic.md#agent-long-text-input) | Block for long text input (multi-line) | +| [Agent Number Input](block-integrations/basic.md#agent-number-input) | Block for number input | +| [Agent Output](block-integrations/basic.md#agent-output) | A block that records and formats workflow results for display to users, with optional Jinja2 template formatting support | +| [Agent Short Text Input](block-integrations/basic.md#agent-short-text-input) | Block for short text input (single-line) | +| [Agent Table Input](block-integrations/basic.md#agent-table-input) | Block for table data input with customizable headers | +| [Agent Time Input](block-integrations/basic.md#agent-time-input) | Block for time input | +| [Agent Toggle Input](block-integrations/basic.md#agent-toggle-input) | Block for boolean toggle input | +| [Block Installation](block-integrations/basic.md#block-installation) | Given a code string, this block allows the verification and installation of a block code into the system | +| [Concatenate Lists](block-integrations/basic.md#concatenate-lists) | Concatenates multiple lists into a single list | +| [Dictionary Is Empty](block-integrations/basic.md#dictionary-is-empty) | Checks if a dictionary is empty | +| [File Store](block-integrations/basic.md#file-store) | Stores the input file in the temporary directory | +| [Find In Dictionary](block-integrations/basic.md#find-in-dictionary) | A block that looks up a value in a dictionary, list, or object by key or index and returns the corresponding value | +| [Find In List](block-integrations/basic.md#find-in-list) | Finds the index of the value in the list | +| [Get All Memories](block-integrations/basic.md#get-all-memories) | Retrieve all memories from Mem0 with optional conversation filtering | +| [Get Latest Memory](block-integrations/basic.md#get-latest-memory) | Retrieve the latest memory from Mem0 with optional key filtering | +| [Get List Item](block-integrations/basic.md#get-list-item) | Returns the element at the given index | +| [Get Store Agent Details](block-integrations/system/store_operations.md#get-store-agent-details) | Get detailed information about an agent from the store | +| [Get Weather Information](block-integrations/basic.md#get-weather-information) | Retrieves weather information for a specified location using OpenWeatherMap API | +| [Human In The Loop](block-integrations/basic.md#human-in-the-loop) | Pause execution and wait for human approval or modification of data | +| [Linear Search Issues](block-integrations/linear/issues.md#linear-search-issues) | Searches for issues on Linear | +| [List Is Empty](block-integrations/basic.md#list-is-empty) | Checks if a list is empty | +| [List Library Agents](block-integrations/system/library_operations.md#list-library-agents) | List all agents in your personal library | +| [Note](block-integrations/basic.md#note) | A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes | +| [Print To Console](block-integrations/basic.md#print-to-console) | A debugging block that outputs text to the console for monitoring and troubleshooting workflow execution | +| [Remove From Dictionary](block-integrations/basic.md#remove-from-dictionary) | Removes a key-value pair from a dictionary | +| [Remove From List](block-integrations/basic.md#remove-from-list) | Removes an item from a list by value or index | +| [Replace Dictionary Value](block-integrations/basic.md#replace-dictionary-value) | Replaces the value for a specified key in a dictionary | +| [Replace List Item](block-integrations/basic.md#replace-list-item) | Replaces an item at the specified index | +| [Reverse List Order](block-integrations/basic.md#reverse-list-order) | Reverses the order of elements in a list | +| [Search Memory](block-integrations/basic.md#search-memory) | Search memories in Mem0 by user | +| [Search Store Agents](block-integrations/system/store_operations.md#search-store-agents) | Search for agents in the store | +| [Slant3D Cancel Order](block-integrations/slant3d/order.md#slant3d-cancel-order) | Cancel an existing order | +| [Slant3D Create Order](block-integrations/slant3d/order.md#slant3d-create-order) | Create a new print order | +| [Slant3D Estimate Order](block-integrations/slant3d/order.md#slant3d-estimate-order) | Get order cost estimate | +| [Slant3D Estimate Shipping](block-integrations/slant3d/order.md#slant3d-estimate-shipping) | Get shipping cost estimate | +| [Slant3D Filament](block-integrations/slant3d/filament.md#slant3d-filament) | Get list of available filaments | +| [Slant3D Get Orders](block-integrations/slant3d/order.md#slant3d-get-orders) | Get all orders for the account | +| [Slant3D Slicer](block-integrations/slant3d/slicing.md#slant3d-slicer) | Slice a 3D model file and get pricing information | +| [Slant3D Tracking](block-integrations/slant3d/order.md#slant3d-tracking) | Track order status and shipping | +| [Store Value](block-integrations/basic.md#store-value) | A basic block that stores and forwards a value throughout workflows, allowing it to be reused without changes across multiple blocks | +| [Universal Type Converter](block-integrations/basic.md#universal-type-converter) | This block is used to convert a value to a universal type | +| [XML Parser](block-integrations/basic.md#xml-parser) | Parses XML using gravitasml to tokenize and coverts it to dict | ## Data Processing | Block Name | Description | |------------|-------------| -| [Airtable Create Base](airtable/bases.md#airtable-create-base) | Create or find a base in Airtable | -| [Airtable Create Field](airtable/schema.md#airtable-create-field) | Add a new field to an Airtable table | -| [Airtable Create Records](airtable/records.md#airtable-create-records) | Create records in an Airtable table | -| [Airtable Create Table](airtable/schema.md#airtable-create-table) | Create a new table in an Airtable base | -| [Airtable Delete Records](airtable/records.md#airtable-delete-records) | Delete records from an Airtable table | -| [Airtable Get Record](airtable/records.md#airtable-get-record) | Get a single record from Airtable | -| [Airtable List Bases](airtable/bases.md#airtable-list-bases) | List all bases in Airtable | -| [Airtable List Records](airtable/records.md#airtable-list-records) | List records from an Airtable table | -| [Airtable List Schema](airtable/schema.md#airtable-list-schema) | Get the complete schema of an Airtable base | -| [Airtable Update Field](airtable/schema.md#airtable-update-field) | Update field properties in an Airtable table | -| [Airtable Update Records](airtable/records.md#airtable-update-records) | Update records in an Airtable table | -| [Airtable Update Table](airtable/schema.md#airtable-update-table) | Update table properties | -| [Airtable Webhook Trigger](airtable/triggers.md#airtable-webhook-trigger) | Starts a flow whenever Airtable emits a webhook event | -| [Baas Bot Delete Recording](baas/bots.md#baas-bot-delete-recording) | Permanently delete a meeting's recorded data | -| [Baas Bot Fetch Meeting Data](baas/bots.md#baas-bot-fetch-meeting-data) | Retrieve recorded meeting data | -| [Create Dictionary](data.md#create-dictionary) | Creates a dictionary with the specified key-value pairs | -| [Create List](data.md#create-list) | Creates a list with the specified values | -| [Data For Seo Keyword Suggestions](dataforseo/keyword_suggestions.md#data-for-seo-keyword-suggestions) | Get keyword suggestions from DataForSEO Labs Google API | -| [Data For Seo Related Keywords](dataforseo/related_keywords.md#data-for-seo-related-keywords) | Get related keywords from DataForSEO Labs Google API | -| [Exa Create Import](exa/websets_import_export.md#exa-create-import) | Import CSV data to use with websets for targeted searches | -| [Exa Delete Import](exa/websets_import_export.md#exa-delete-import) | Delete an import | -| [Exa Export Webset](exa/websets_import_export.md#exa-export-webset) | Export webset data in JSON, CSV, or JSON Lines format | -| [Exa Get Import](exa/websets_import_export.md#exa-get-import) | Get the status and details of an import | -| [Exa Get New Items](exa/websets_items.md#exa-get-new-items) | Get items added since a cursor - enables incremental processing without reprocessing | -| [Exa List Imports](exa/websets_import_export.md#exa-list-imports) | List all imports with pagination support | -| [File Read](data.md#file-read) | Reads a file and returns its content as a string, with optional chunking by delimiter and size limits | -| [Google Calendar Read Events](google/calendar.md#google-calendar-read-events) | Retrieves upcoming events from a Google Calendar with filtering options | -| [Google Docs Append Markdown](google/docs.md#google-docs-append-markdown) | Append Markdown content to the end of a Google Doc with full formatting - ideal for LLM/AI output | -| [Google Docs Append Plain Text](google/docs.md#google-docs-append-plain-text) | Append plain text to the end of a Google Doc (no formatting applied) | -| [Google Docs Create](google/docs.md#google-docs-create) | Create a new Google Doc | -| [Google Docs Delete Content](google/docs.md#google-docs-delete-content) | Delete a range of content from a Google Doc | -| [Google Docs Export](google/docs.md#google-docs-export) | Export a Google Doc to PDF, Word, text, or other formats | -| [Google Docs Find Replace Plain Text](google/docs.md#google-docs-find-replace-plain-text) | Find and replace plain text in a Google Doc (no formatting applied to replacement) | -| [Google Docs Format Text](google/docs.md#google-docs-format-text) | Apply formatting (bold, italic, color, etc | -| [Google Docs Get Metadata](google/docs.md#google-docs-get-metadata) | Get metadata about a Google Doc | -| [Google Docs Get Structure](google/docs.md#google-docs-get-structure) | Get document structure with index positions for precise editing operations | -| [Google Docs Insert Markdown At](google/docs.md#google-docs-insert-markdown-at) | Insert formatted Markdown at a specific position in a Google Doc - ideal for LLM/AI output | -| [Google Docs Insert Page Break](google/docs.md#google-docs-insert-page-break) | Insert a page break into a Google Doc | -| [Google Docs Insert Plain Text](google/docs.md#google-docs-insert-plain-text) | Insert plain text at a specific position in a Google Doc (no formatting applied) | -| [Google Docs Insert Table](google/docs.md#google-docs-insert-table) | Insert a table into a Google Doc, optionally with content and Markdown formatting | -| [Google Docs Read](google/docs.md#google-docs-read) | Read text content from a Google Doc | -| [Google Docs Replace All With Markdown](google/docs.md#google-docs-replace-all-with-markdown) | Replace entire Google Doc content with formatted Markdown - ideal for LLM/AI output | -| [Google Docs Replace Content With Markdown](google/docs.md#google-docs-replace-content-with-markdown) | Find text and replace it with formatted Markdown - ideal for LLM/AI output and templates | -| [Google Docs Replace Range With Markdown](google/docs.md#google-docs-replace-range-with-markdown) | Replace a specific index range in a Google Doc with formatted Markdown - ideal for LLM/AI output | -| [Google Docs Set Public Access](google/docs.md#google-docs-set-public-access) | Make a Google Doc public or private | -| [Google Docs Share](google/docs.md#google-docs-share) | Share a Google Doc with specific users | -| [Google Sheets Add Column](google/sheets.md#google-sheets-add-column) | Add a new column with a header | -| [Google Sheets Add Dropdown](google/sheets.md#google-sheets-add-dropdown) | Add a dropdown list (data validation) to cells | -| [Google Sheets Add Note](google/sheets.md#google-sheets-add-note) | Add a note to a cell in a Google Sheet | -| [Google Sheets Append Row](google/sheets.md#google-sheets-append-row) | Append or Add a single row to the end of a Google Sheet | -| [Google Sheets Batch Operations](google/sheets.md#google-sheets-batch-operations) | This block performs multiple operations on a Google Sheets spreadsheet in a single batch request | -| [Google Sheets Clear](google/sheets.md#google-sheets-clear) | This block clears data from a specified range in a Google Sheets spreadsheet | -| [Google Sheets Copy To Spreadsheet](google/sheets.md#google-sheets-copy-to-spreadsheet) | Copy a sheet from one spreadsheet to another | -| [Google Sheets Create Named Range](google/sheets.md#google-sheets-create-named-range) | Create a named range to reference cells by name instead of A1 notation | -| [Google Sheets Create Spreadsheet](google/sheets.md#google-sheets-create-spreadsheet) | This block creates a new Google Sheets spreadsheet with specified sheets | -| [Google Sheets Delete Column](google/sheets.md#google-sheets-delete-column) | Delete a column by header name or column letter | -| [Google Sheets Delete Rows](google/sheets.md#google-sheets-delete-rows) | Delete specific rows from a Google Sheet by their row indices | -| [Google Sheets Export Csv](google/sheets.md#google-sheets-export-csv) | Export a Google Sheet as CSV data | -| [Google Sheets Filter Rows](google/sheets.md#google-sheets-filter-rows) | Filter rows in a Google Sheet based on a column condition | -| [Google Sheets Find](google/sheets.md#google-sheets-find) | Find text in a Google Sheets spreadsheet | -| [Google Sheets Find Replace](google/sheets.md#google-sheets-find-replace) | This block finds and replaces text in a Google Sheets spreadsheet | -| [Google Sheets Format](google/sheets.md#google-sheets-format) | Format a range in a Google Sheet (sheet optional) | -| [Google Sheets Get Column](google/sheets.md#google-sheets-get-column) | Extract all values from a specific column | -| [Google Sheets Get Notes](google/sheets.md#google-sheets-get-notes) | Get notes from cells in a Google Sheet | -| [Google Sheets Get Row](google/sheets.md#google-sheets-get-row) | Get a specific row by its index | -| [Google Sheets Get Row Count](google/sheets.md#google-sheets-get-row-count) | Get row count and dimensions of a Google Sheet | -| [Google Sheets Get Unique Values](google/sheets.md#google-sheets-get-unique-values) | Get unique values from a column | -| [Google Sheets Import Csv](google/sheets.md#google-sheets-import-csv) | Import CSV data into a Google Sheet | -| [Google Sheets Insert Row](google/sheets.md#google-sheets-insert-row) | Insert a single row at a specific position | -| [Google Sheets List Named Ranges](google/sheets.md#google-sheets-list-named-ranges) | List all named ranges in a spreadsheet | -| [Google Sheets Lookup Row](google/sheets.md#google-sheets-lookup-row) | Look up a row by finding a value in a specific column | -| [Google Sheets Manage Sheet](google/sheets.md#google-sheets-manage-sheet) | Create, delete, or copy sheets (sheet optional) | -| [Google Sheets Metadata](google/sheets.md#google-sheets-metadata) | This block retrieves metadata about a Google Sheets spreadsheet including sheet names and properties | -| [Google Sheets Protect Range](google/sheets.md#google-sheets-protect-range) | Protect a cell range or entire sheet from editing | -| [Google Sheets Read](google/sheets.md#google-sheets-read) | A block that reads data from a Google Sheets spreadsheet using A1 notation range selection | -| [Google Sheets Remove Duplicates](google/sheets.md#google-sheets-remove-duplicates) | Remove duplicate rows based on specified columns | -| [Google Sheets Set Public Access](google/sheets.md#google-sheets-set-public-access) | Make a Google Spreadsheet public or private | -| [Google Sheets Share Spreadsheet](google/sheets.md#google-sheets-share-spreadsheet) | Share a Google Spreadsheet with users or get shareable link | -| [Google Sheets Sort](google/sheets.md#google-sheets-sort) | Sort a Google Sheet by one or two columns | -| [Google Sheets Update Cell](google/sheets.md#google-sheets-update-cell) | Update a single cell in a Google Sheets spreadsheet | -| [Google Sheets Update Row](google/sheets.md#google-sheets-update-row) | Update a specific row by its index | -| [Google Sheets Write](google/sheets.md#google-sheets-write) | A block that writes data to a Google Sheets spreadsheet at a specified A1 notation range | -| [Keyword Suggestion Extractor](dataforseo/keyword_suggestions.md#keyword-suggestion-extractor) | Extract individual fields from a KeywordSuggestion object | -| [Persist Information](data.md#persist-information) | Persist key-value information for the current user | -| [Read Spreadsheet](data.md#read-spreadsheet) | Reads CSV and Excel files and outputs the data as a list of dictionaries and individual rows | -| [Related Keyword Extractor](dataforseo/related_keywords.md#related-keyword-extractor) | Extract individual fields from a RelatedKeyword object | -| [Retrieve Information](data.md#retrieve-information) | Retrieve key-value information for the current user | -| [Screenshot Web Page](data.md#screenshot-web-page) | Takes a screenshot of a specified website using ScreenshotOne API | +| [Airtable Create Base](block-integrations/airtable/bases.md#airtable-create-base) | Create or find a base in Airtable | +| [Airtable Create Field](block-integrations/airtable/schema.md#airtable-create-field) | Add a new field to an Airtable table | +| [Airtable Create Records](block-integrations/airtable/records.md#airtable-create-records) | Create records in an Airtable table | +| [Airtable Create Table](block-integrations/airtable/schema.md#airtable-create-table) | Create a new table in an Airtable base | +| [Airtable Delete Records](block-integrations/airtable/records.md#airtable-delete-records) | Delete records from an Airtable table | +| [Airtable Get Record](block-integrations/airtable/records.md#airtable-get-record) | Get a single record from Airtable | +| [Airtable List Bases](block-integrations/airtable/bases.md#airtable-list-bases) | List all bases in Airtable | +| [Airtable List Records](block-integrations/airtable/records.md#airtable-list-records) | List records from an Airtable table | +| [Airtable List Schema](block-integrations/airtable/schema.md#airtable-list-schema) | Get the complete schema of an Airtable base | +| [Airtable Update Field](block-integrations/airtable/schema.md#airtable-update-field) | Update field properties in an Airtable table | +| [Airtable Update Records](block-integrations/airtable/records.md#airtable-update-records) | Update records in an Airtable table | +| [Airtable Update Table](block-integrations/airtable/schema.md#airtable-update-table) | Update table properties | +| [Airtable Webhook Trigger](block-integrations/airtable/triggers.md#airtable-webhook-trigger) | Starts a flow whenever Airtable emits a webhook event | +| [Baas Bot Delete Recording](block-integrations/baas/bots.md#baas-bot-delete-recording) | Permanently delete a meeting's recorded data | +| [Baas Bot Fetch Meeting Data](block-integrations/baas/bots.md#baas-bot-fetch-meeting-data) | Retrieve recorded meeting data | +| [Create Dictionary](block-integrations/data.md#create-dictionary) | Creates a dictionary with the specified key-value pairs | +| [Create List](block-integrations/data.md#create-list) | Creates a list with the specified values | +| [Data For Seo Keyword Suggestions](block-integrations/dataforseo/keyword_suggestions.md#data-for-seo-keyword-suggestions) | Get keyword suggestions from DataForSEO Labs Google API | +| [Data For Seo Related Keywords](block-integrations/dataforseo/related_keywords.md#data-for-seo-related-keywords) | Get related keywords from DataForSEO Labs Google API | +| [Exa Create Import](block-integrations/exa/websets_import_export.md#exa-create-import) | Import CSV data to use with websets for targeted searches | +| [Exa Delete Import](block-integrations/exa/websets_import_export.md#exa-delete-import) | Delete an import | +| [Exa Export Webset](block-integrations/exa/websets_import_export.md#exa-export-webset) | Export webset data in JSON, CSV, or JSON Lines format | +| [Exa Get Import](block-integrations/exa/websets_import_export.md#exa-get-import) | Get the status and details of an import | +| [Exa Get New Items](block-integrations/exa/websets_items.md#exa-get-new-items) | Get items added since a cursor - enables incremental processing without reprocessing | +| [Exa List Imports](block-integrations/exa/websets_import_export.md#exa-list-imports) | List all imports with pagination support | +| [File Read](block-integrations/data.md#file-read) | Reads a file and returns its content as a string, with optional chunking by delimiter and size limits | +| [Google Calendar Read Events](block-integrations/google/calendar.md#google-calendar-read-events) | Retrieves upcoming events from a Google Calendar with filtering options | +| [Google Docs Append Markdown](block-integrations/google/docs.md#google-docs-append-markdown) | Append Markdown content to the end of a Google Doc with full formatting - ideal for LLM/AI output | +| [Google Docs Append Plain Text](block-integrations/google/docs.md#google-docs-append-plain-text) | Append plain text to the end of a Google Doc (no formatting applied) | +| [Google Docs Create](block-integrations/google/docs.md#google-docs-create) | Create a new Google Doc | +| [Google Docs Delete Content](block-integrations/google/docs.md#google-docs-delete-content) | Delete a range of content from a Google Doc | +| [Google Docs Export](block-integrations/google/docs.md#google-docs-export) | Export a Google Doc to PDF, Word, text, or other formats | +| [Google Docs Find Replace Plain Text](block-integrations/google/docs.md#google-docs-find-replace-plain-text) | Find and replace plain text in a Google Doc (no formatting applied to replacement) | +| [Google Docs Format Text](block-integrations/google/docs.md#google-docs-format-text) | Apply formatting (bold, italic, color, etc | +| [Google Docs Get Metadata](block-integrations/google/docs.md#google-docs-get-metadata) | Get metadata about a Google Doc | +| [Google Docs Get Structure](block-integrations/google/docs.md#google-docs-get-structure) | Get document structure with index positions for precise editing operations | +| [Google Docs Insert Markdown At](block-integrations/google/docs.md#google-docs-insert-markdown-at) | Insert formatted Markdown at a specific position in a Google Doc - ideal for LLM/AI output | +| [Google Docs Insert Page Break](block-integrations/google/docs.md#google-docs-insert-page-break) | Insert a page break into a Google Doc | +| [Google Docs Insert Plain Text](block-integrations/google/docs.md#google-docs-insert-plain-text) | Insert plain text at a specific position in a Google Doc (no formatting applied) | +| [Google Docs Insert Table](block-integrations/google/docs.md#google-docs-insert-table) | Insert a table into a Google Doc, optionally with content and Markdown formatting | +| [Google Docs Read](block-integrations/google/docs.md#google-docs-read) | Read text content from a Google Doc | +| [Google Docs Replace All With Markdown](block-integrations/google/docs.md#google-docs-replace-all-with-markdown) | Replace entire Google Doc content with formatted Markdown - ideal for LLM/AI output | +| [Google Docs Replace Content With Markdown](block-integrations/google/docs.md#google-docs-replace-content-with-markdown) | Find text and replace it with formatted Markdown - ideal for LLM/AI output and templates | +| [Google Docs Replace Range With Markdown](block-integrations/google/docs.md#google-docs-replace-range-with-markdown) | Replace a specific index range in a Google Doc with formatted Markdown - ideal for LLM/AI output | +| [Google Docs Set Public Access](block-integrations/google/docs.md#google-docs-set-public-access) | Make a Google Doc public or private | +| [Google Docs Share](block-integrations/google/docs.md#google-docs-share) | Share a Google Doc with specific users | +| [Google Sheets Add Column](block-integrations/google/sheets.md#google-sheets-add-column) | Add a new column with a header | +| [Google Sheets Add Dropdown](block-integrations/google/sheets.md#google-sheets-add-dropdown) | Add a dropdown list (data validation) to cells | +| [Google Sheets Add Note](block-integrations/google/sheets.md#google-sheets-add-note) | Add a note to a cell in a Google Sheet | +| [Google Sheets Append Row](block-integrations/google/sheets.md#google-sheets-append-row) | Append or Add a single row to the end of a Google Sheet | +| [Google Sheets Batch Operations](block-integrations/google/sheets.md#google-sheets-batch-operations) | This block performs multiple operations on a Google Sheets spreadsheet in a single batch request | +| [Google Sheets Clear](block-integrations/google/sheets.md#google-sheets-clear) | This block clears data from a specified range in a Google Sheets spreadsheet | +| [Google Sheets Copy To Spreadsheet](block-integrations/google/sheets.md#google-sheets-copy-to-spreadsheet) | Copy a sheet from one spreadsheet to another | +| [Google Sheets Create Named Range](block-integrations/google/sheets.md#google-sheets-create-named-range) | Create a named range to reference cells by name instead of A1 notation | +| [Google Sheets Create Spreadsheet](block-integrations/google/sheets.md#google-sheets-create-spreadsheet) | This block creates a new Google Sheets spreadsheet with specified sheets | +| [Google Sheets Delete Column](block-integrations/google/sheets.md#google-sheets-delete-column) | Delete a column by header name or column letter | +| [Google Sheets Delete Rows](block-integrations/google/sheets.md#google-sheets-delete-rows) | Delete specific rows from a Google Sheet by their row indices | +| [Google Sheets Export Csv](block-integrations/google/sheets.md#google-sheets-export-csv) | Export a Google Sheet as CSV data | +| [Google Sheets Filter Rows](block-integrations/google/sheets.md#google-sheets-filter-rows) | Filter rows in a Google Sheet based on a column condition | +| [Google Sheets Find](block-integrations/google/sheets.md#google-sheets-find) | Find text in a Google Sheets spreadsheet | +| [Google Sheets Find Replace](block-integrations/google/sheets.md#google-sheets-find-replace) | This block finds and replaces text in a Google Sheets spreadsheet | +| [Google Sheets Format](block-integrations/google/sheets.md#google-sheets-format) | Format a range in a Google Sheet (sheet optional) | +| [Google Sheets Get Column](block-integrations/google/sheets.md#google-sheets-get-column) | Extract all values from a specific column | +| [Google Sheets Get Notes](block-integrations/google/sheets.md#google-sheets-get-notes) | Get notes from cells in a Google Sheet | +| [Google Sheets Get Row](block-integrations/google/sheets.md#google-sheets-get-row) | Get a specific row by its index | +| [Google Sheets Get Row Count](block-integrations/google/sheets.md#google-sheets-get-row-count) | Get row count and dimensions of a Google Sheet | +| [Google Sheets Get Unique Values](block-integrations/google/sheets.md#google-sheets-get-unique-values) | Get unique values from a column | +| [Google Sheets Import Csv](block-integrations/google/sheets.md#google-sheets-import-csv) | Import CSV data into a Google Sheet | +| [Google Sheets Insert Row](block-integrations/google/sheets.md#google-sheets-insert-row) | Insert a single row at a specific position | +| [Google Sheets List Named Ranges](block-integrations/google/sheets.md#google-sheets-list-named-ranges) | List all named ranges in a spreadsheet | +| [Google Sheets Lookup Row](block-integrations/google/sheets.md#google-sheets-lookup-row) | Look up a row by finding a value in a specific column | +| [Google Sheets Manage Sheet](block-integrations/google/sheets.md#google-sheets-manage-sheet) | Create, delete, or copy sheets (sheet optional) | +| [Google Sheets Metadata](block-integrations/google/sheets.md#google-sheets-metadata) | This block retrieves metadata about a Google Sheets spreadsheet including sheet names and properties | +| [Google Sheets Protect Range](block-integrations/google/sheets.md#google-sheets-protect-range) | Protect a cell range or entire sheet from editing | +| [Google Sheets Read](block-integrations/google/sheets.md#google-sheets-read) | A block that reads data from a Google Sheets spreadsheet using A1 notation range selection | +| [Google Sheets Remove Duplicates](block-integrations/google/sheets.md#google-sheets-remove-duplicates) | Remove duplicate rows based on specified columns | +| [Google Sheets Set Public Access](block-integrations/google/sheets.md#google-sheets-set-public-access) | Make a Google Spreadsheet public or private | +| [Google Sheets Share Spreadsheet](block-integrations/google/sheets.md#google-sheets-share-spreadsheet) | Share a Google Spreadsheet with users or get shareable link | +| [Google Sheets Sort](block-integrations/google/sheets.md#google-sheets-sort) | Sort a Google Sheet by one or two columns | +| [Google Sheets Update Cell](block-integrations/google/sheets.md#google-sheets-update-cell) | Update a single cell in a Google Sheets spreadsheet | +| [Google Sheets Update Row](block-integrations/google/sheets.md#google-sheets-update-row) | Update a specific row by its index | +| [Google Sheets Write](block-integrations/google/sheets.md#google-sheets-write) | A block that writes data to a Google Sheets spreadsheet at a specified A1 notation range | +| [Keyword Suggestion Extractor](block-integrations/dataforseo/keyword_suggestions.md#keyword-suggestion-extractor) | Extract individual fields from a KeywordSuggestion object | +| [Persist Information](block-integrations/data.md#persist-information) | Persist key-value information for the current user | +| [Read Spreadsheet](block-integrations/data.md#read-spreadsheet) | Reads CSV and Excel files and outputs the data as a list of dictionaries and individual rows | +| [Related Keyword Extractor](block-integrations/dataforseo/related_keywords.md#related-keyword-extractor) | Extract individual fields from a RelatedKeyword object | +| [Retrieve Information](block-integrations/data.md#retrieve-information) | Retrieve key-value information for the current user | +| [Screenshot Web Page](block-integrations/data.md#screenshot-web-page) | Takes a screenshot of a specified website using ScreenshotOne API | ## Text Processing | Block Name | Description | |------------|-------------| -| [Code Extraction](text.md#code-extraction) | Extracts code blocks from text and identifies their programming languages | -| [Combine Texts](text.md#combine-texts) | This block combines multiple input texts into a single output text | -| [Countdown Timer](text.md#countdown-timer) | This block triggers after a specified duration | -| [Extract Text Information](text.md#extract-text-information) | This block extracts the text from the given text using the pattern (regex) | -| [Fill Text Template](text.md#fill-text-template) | This block formats the given texts using the format template | -| [Get Current Date](text.md#get-current-date) | This block outputs the current date with an optional offset | -| [Get Current Date And Time](text.md#get-current-date-and-time) | This block outputs the current date and time | -| [Get Current Time](text.md#get-current-time) | This block outputs the current time | -| [Match Text Pattern](text.md#match-text-pattern) | Matches text against a regex pattern and forwards data to positive or negative output based on the match | -| [Text Decoder](text.md#text-decoder) | Decodes a string containing escape sequences into actual text | -| [Text Replace](text.md#text-replace) | This block is used to replace a text with a new text | -| [Text Split](text.md#text-split) | This block is used to split a text into a list of strings | -| [Word Character Count](text.md#word-character-count) | Counts the number of words and characters in a given text | +| [Code Extraction](block-integrations/text.md#code-extraction) | Extracts code blocks from text and identifies their programming languages | +| [Combine Texts](block-integrations/text.md#combine-texts) | This block combines multiple input texts into a single output text | +| [Countdown Timer](block-integrations/text.md#countdown-timer) | This block triggers after a specified duration | +| [Extract Text Information](block-integrations/text.md#extract-text-information) | This block extracts the text from the given text using the pattern (regex) | +| [Fill Text Template](block-integrations/text.md#fill-text-template) | This block formats the given texts using the format template | +| [Get Current Date](block-integrations/text.md#get-current-date) | This block outputs the current date with an optional offset | +| [Get Current Date And Time](block-integrations/text.md#get-current-date-and-time) | This block outputs the current date and time | +| [Get Current Time](block-integrations/text.md#get-current-time) | This block outputs the current time | +| [Match Text Pattern](block-integrations/text.md#match-text-pattern) | Matches text against a regex pattern and forwards data to positive or negative output based on the match | +| [Text Decoder](block-integrations/text.md#text-decoder) | Decodes a string containing escape sequences into actual text | +| [Text Replace](block-integrations/text.md#text-replace) | This block is used to replace a text with a new text | +| [Text Split](block-integrations/text.md#text-split) | This block is used to split a text into a list of strings | +| [Word Character Count](block-integrations/text.md#word-character-count) | Counts the number of words and characters in a given text | ## AI and Language Models | Block Name | Description | |------------|-------------| -| [AI Ad Maker Video Creator](llm.md#ai-ad-maker-video-creator) | Creates an AI‑generated 30‑second advert (text + images) | -| [AI Condition](llm.md#ai-condition) | Uses AI to evaluate natural language conditions and provide conditional outputs | -| [AI Conversation](llm.md#ai-conversation) | A block that facilitates multi-turn conversations with a Large Language Model (LLM), maintaining context across message exchanges | -| [AI Image Customizer](llm.md#ai-image-customizer) | Generate and edit custom images using Google's Nano-Banana model from Gemini 2 | -| [AI Image Editor](llm.md#ai-image-editor) | Edit images using BlackForest Labs' Flux Kontext models | -| [AI Image Generator](llm.md#ai-image-generator) | Generate images using various AI models through a unified interface | -| [AI List Generator](llm.md#ai-list-generator) | A block that creates lists of items based on prompts using a Large Language Model (LLM), with optional source data for context | -| [AI Music Generator](llm.md#ai-music-generator) | This block generates music using Meta's MusicGen model on Replicate | -| [AI Screenshot To Video Ad](llm.md#ai-screenshot-to-video-ad) | Turns a screenshot into an engaging, avatar‑narrated video advert | -| [AI Shortform Video Creator](llm.md#ai-shortform-video-creator) | Creates a shortform video using revid | -| [AI Structured Response Generator](llm.md#ai-structured-response-generator) | A block that generates structured JSON responses using a Large Language Model (LLM), with schema validation and format enforcement | -| [AI Text Generator](llm.md#ai-text-generator) | A block that produces text responses using a Large Language Model (LLM) based on customizable prompts and system instructions | -| [AI Text Summarizer](llm.md#ai-text-summarizer) | A block that summarizes long texts using a Large Language Model (LLM), with configurable focus topics and summary styles | -| [AI Video Generator](fal/ai_video_generator.md#ai-video-generator) | Generate videos using FAL AI models | -| [Bannerbear Text Overlay](bannerbear/text_overlay.md#bannerbear-text-overlay) | Add text overlay to images using Bannerbear templates | -| [Code Generation](llm.md#code-generation) | Generate or refactor code using OpenAI's Codex (Responses API) | -| [Create Talking Avatar Video](llm.md#create-talking-avatar-video) | This block integrates with D-ID to create video clips and retrieve their URLs | -| [Exa Answer](exa/answers.md#exa-answer) | Get an LLM answer to a question informed by Exa search results | -| [Exa Create Enrichment](exa/websets_enrichment.md#exa-create-enrichment) | Create enrichments to extract additional structured data from webset items | -| [Exa Create Research](exa/research.md#exa-create-research) | Create research task with optional waiting - explores web and synthesizes findings with citations | -| [Ideogram Model](llm.md#ideogram-model) | This block runs Ideogram models with both simple and advanced settings | -| [Jina Chunking](jina/chunking.md#jina-chunking) | Chunks texts using Jina AI's segmentation service | -| [Jina Embedding](jina/embeddings.md#jina-embedding) | Generates embeddings using Jina AI | -| [Perplexity](llm.md#perplexity) | Query Perplexity's sonar models with real-time web search capabilities and receive annotated responses with source citations | -| [Replicate Flux Advanced Model](replicate/flux_advanced.md#replicate-flux-advanced-model) | This block runs Flux models on Replicate with advanced settings | -| [Replicate Model](replicate/replicate_block.md#replicate-model) | Run Replicate models synchronously | -| [Smart Decision Maker](llm.md#smart-decision-maker) | Uses AI to intelligently decide what tool to use | -| [Stagehand Act](stagehand/blocks.md#stagehand-act) | Interact with a web page by performing actions on a web page | -| [Stagehand Extract](stagehand/blocks.md#stagehand-extract) | Extract structured data from a webpage | -| [Stagehand Observe](stagehand/blocks.md#stagehand-observe) | Find suggested actions for your workflows | -| [Unreal Text To Speech](llm.md#unreal-text-to-speech) | Converts text to speech using the Unreal Speech API | +| [AI Ad Maker Video Creator](block-integrations/llm.md#ai-ad-maker-video-creator) | Creates an AI‑generated 30‑second advert (text + images) | +| [AI Condition](block-integrations/llm.md#ai-condition) | Uses AI to evaluate natural language conditions and provide conditional outputs | +| [AI Conversation](block-integrations/llm.md#ai-conversation) | A block that facilitates multi-turn conversations with a Large Language Model (LLM), maintaining context across message exchanges | +| [AI Image Customizer](block-integrations/llm.md#ai-image-customizer) | Generate and edit custom images using Google's Nano-Banana model from Gemini 2 | +| [AI Image Editor](block-integrations/llm.md#ai-image-editor) | Edit images using BlackForest Labs' Flux Kontext models | +| [AI Image Generator](block-integrations/llm.md#ai-image-generator) | Generate images using various AI models through a unified interface | +| [AI List Generator](block-integrations/llm.md#ai-list-generator) | A block that creates lists of items based on prompts using a Large Language Model (LLM), with optional source data for context | +| [AI Music Generator](block-integrations/llm.md#ai-music-generator) | This block generates music using Meta's MusicGen model on Replicate | +| [AI Screenshot To Video Ad](block-integrations/llm.md#ai-screenshot-to-video-ad) | Turns a screenshot into an engaging, avatar‑narrated video advert | +| [AI Shortform Video Creator](block-integrations/llm.md#ai-shortform-video-creator) | Creates a shortform video using revid | +| [AI Structured Response Generator](block-integrations/llm.md#ai-structured-response-generator) | A block that generates structured JSON responses using a Large Language Model (LLM), with schema validation and format enforcement | +| [AI Text Generator](block-integrations/llm.md#ai-text-generator) | A block that produces text responses using a Large Language Model (LLM) based on customizable prompts and system instructions | +| [AI Text Summarizer](block-integrations/llm.md#ai-text-summarizer) | A block that summarizes long texts using a Large Language Model (LLM), with configurable focus topics and summary styles | +| [AI Video Generator](block-integrations/fal/ai_video_generator.md#ai-video-generator) | Generate videos using FAL AI models | +| [Bannerbear Text Overlay](block-integrations/bannerbear/text_overlay.md#bannerbear-text-overlay) | Add text overlay to images using Bannerbear templates | +| [Code Generation](block-integrations/llm.md#code-generation) | Generate or refactor code using OpenAI's Codex (Responses API) | +| [Create Talking Avatar Video](block-integrations/llm.md#create-talking-avatar-video) | This block integrates with D-ID to create video clips and retrieve their URLs | +| [Exa Answer](block-integrations/exa/answers.md#exa-answer) | Get an LLM answer to a question informed by Exa search results | +| [Exa Create Enrichment](block-integrations/exa/websets_enrichment.md#exa-create-enrichment) | Create enrichments to extract additional structured data from webset items | +| [Exa Create Research](block-integrations/exa/research.md#exa-create-research) | Create research task with optional waiting - explores web and synthesizes findings with citations | +| [Ideogram Model](block-integrations/llm.md#ideogram-model) | This block runs Ideogram models with both simple and advanced settings | +| [Jina Chunking](block-integrations/jina/chunking.md#jina-chunking) | Chunks texts using Jina AI's segmentation service | +| [Jina Embedding](block-integrations/jina/embeddings.md#jina-embedding) | Generates embeddings using Jina AI | +| [Perplexity](block-integrations/llm.md#perplexity) | Query Perplexity's sonar models with real-time web search capabilities and receive annotated responses with source citations | +| [Replicate Flux Advanced Model](block-integrations/replicate/flux_advanced.md#replicate-flux-advanced-model) | This block runs Flux models on Replicate with advanced settings | +| [Replicate Model](block-integrations/replicate/replicate_block.md#replicate-model) | Run Replicate models synchronously | +| [Smart Decision Maker](block-integrations/llm.md#smart-decision-maker) | Uses AI to intelligently decide what tool to use | +| [Stagehand Act](block-integrations/stagehand/blocks.md#stagehand-act) | Interact with a web page by performing actions on a web page | +| [Stagehand Extract](block-integrations/stagehand/blocks.md#stagehand-extract) | Extract structured data from a webpage | +| [Stagehand Observe](block-integrations/stagehand/blocks.md#stagehand-observe) | Find suggested actions for your workflows | +| [Unreal Text To Speech](block-integrations/llm.md#unreal-text-to-speech) | Converts text to speech using the Unreal Speech API | ## Search and Information Retrieval | Block Name | Description | |------------|-------------| -| [Ask Wolfram](wolfram/llm_api.md#ask-wolfram) | Ask Wolfram Alpha a question | -| [Exa Bulk Webset Items](exa/websets_items.md#exa-bulk-webset-items) | Get all items from a webset in bulk (with configurable limits) | -| [Exa Cancel Enrichment](exa/websets_enrichment.md#exa-cancel-enrichment) | Cancel a running enrichment operation | -| [Exa Cancel Webset](exa/websets.md#exa-cancel-webset) | Cancel all operations being performed on a Webset | -| [Exa Cancel Webset Search](exa/websets_search.md#exa-cancel-webset-search) | Cancel a running webset search | -| [Exa Contents](exa/contents.md#exa-contents) | Retrieves document contents using Exa's contents API | -| [Exa Create Monitor](exa/websets_monitor.md#exa-create-monitor) | Create automated monitors to keep websets updated with fresh data on a schedule | -| [Exa Create Or Find Webset](exa/websets.md#exa-create-or-find-webset) | Create a new webset or return existing one by external_id (idempotent operation) | -| [Exa Create Webset](exa/websets.md#exa-create-webset) | Create a new Exa Webset for persistent web search collections with optional waiting for initial results | -| [Exa Create Webset Search](exa/websets_search.md#exa-create-webset-search) | Add a new search to an existing webset to find more items | -| [Exa Delete Enrichment](exa/websets_enrichment.md#exa-delete-enrichment) | Delete an enrichment from a webset | -| [Exa Delete Monitor](exa/websets_monitor.md#exa-delete-monitor) | Delete a monitor from a webset | -| [Exa Delete Webset](exa/websets.md#exa-delete-webset) | Delete a Webset and all its items | -| [Exa Delete Webset Item](exa/websets_items.md#exa-delete-webset-item) | Delete a specific item from a webset | -| [Exa Find Or Create Search](exa/websets_search.md#exa-find-or-create-search) | Find existing search by query or create new - prevents duplicate searches in workflows | -| [Exa Find Similar](exa/similar.md#exa-find-similar) | Finds similar links using Exa's findSimilar API | -| [Exa Get Enrichment](exa/websets_enrichment.md#exa-get-enrichment) | Get the status and details of a webset enrichment | -| [Exa Get Monitor](exa/websets_monitor.md#exa-get-monitor) | Get the details and status of a webset monitor | -| [Exa Get Research](exa/research.md#exa-get-research) | Get status and results of a research task | -| [Exa Get Webset](exa/websets.md#exa-get-webset) | Retrieve a Webset by ID or external ID | -| [Exa Get Webset Item](exa/websets_items.md#exa-get-webset-item) | Get a specific item from a webset by its ID | -| [Exa Get Webset Search](exa/websets_search.md#exa-get-webset-search) | Get the status and details of a webset search | -| [Exa List Monitors](exa/websets_monitor.md#exa-list-monitors) | List all monitors with optional webset filtering | -| [Exa List Research](exa/research.md#exa-list-research) | List all research tasks with pagination support | -| [Exa List Webset Items](exa/websets_items.md#exa-list-webset-items) | List items in a webset with pagination support | -| [Exa List Websets](exa/websets.md#exa-list-websets) | List all Websets with pagination support | -| [Exa Preview Webset](exa/websets.md#exa-preview-webset) | Preview how a search query will be interpreted before creating a webset | -| [Exa Search](exa/search.md#exa-search) | Searches the web using Exa's advanced search API | -| [Exa Update Enrichment](exa/websets_enrichment.md#exa-update-enrichment) | Update an existing enrichment configuration | -| [Exa Update Monitor](exa/websets_monitor.md#exa-update-monitor) | Update a monitor's status, schedule, or metadata | -| [Exa Update Webset](exa/websets.md#exa-update-webset) | Update metadata for an existing Webset | -| [Exa Wait For Enrichment](exa/websets_polling.md#exa-wait-for-enrichment) | Wait for a webset enrichment to complete with progress tracking | -| [Exa Wait For Research](exa/research.md#exa-wait-for-research) | Wait for a research task to complete with configurable timeout | -| [Exa Wait For Search](exa/websets_polling.md#exa-wait-for-search) | Wait for a specific webset search to complete with progress tracking | -| [Exa Wait For Webset](exa/websets_polling.md#exa-wait-for-webset) | Wait for a webset to reach a specific status with progress tracking | -| [Exa Webset Items Summary](exa/websets_items.md#exa-webset-items-summary) | Get a summary of webset items without retrieving all data | -| [Exa Webset Status](exa/websets.md#exa-webset-status) | Get a quick status overview of a webset | -| [Exa Webset Summary](exa/websets.md#exa-webset-summary) | Get a comprehensive summary of a webset with samples and statistics | -| [Extract Website Content](jina/search.md#extract-website-content) | This block scrapes the content from the given web URL | -| [Fact Checker](jina/fact_checker.md#fact-checker) | This block checks the factuality of a given statement using Jina AI's Grounding API | -| [Firecrawl Crawl](firecrawl/crawl.md#firecrawl-crawl) | Firecrawl crawls websites to extract comprehensive data while bypassing blockers | -| [Firecrawl Extract](firecrawl/extract.md#firecrawl-extract) | Firecrawl crawls websites to extract comprehensive data while bypassing blockers | -| [Firecrawl Map Website](firecrawl/map.md#firecrawl-map-website) | Firecrawl maps a website to extract all the links | -| [Firecrawl Scrape](firecrawl/scrape.md#firecrawl-scrape) | Firecrawl scrapes a website to extract comprehensive data while bypassing blockers | -| [Firecrawl Search](firecrawl/search.md#firecrawl-search) | Firecrawl searches the web for the given query | -| [Get Person Detail](apollo/person.md#get-person-detail) | Get detailed person data with Apollo API, including email reveal | -| [Get Wikipedia Summary](search.md#get-wikipedia-summary) | This block fetches the summary of a given topic from Wikipedia | -| [Google Maps Search](search.md#google-maps-search) | This block searches for local businesses using Google Maps API | -| [Search Organizations](apollo/organization.md#search-organizations) | Search for organizations in Apollo | -| [Search People](apollo/people.md#search-people) | Search for people in Apollo | -| [Search The Web](jina/search.md#search-the-web) | This block searches the internet for the given search query | -| [Validate Emails](zerobounce/validate_emails.md#validate-emails) | Validate emails | +| [Ask Wolfram](block-integrations/wolfram/llm_api.md#ask-wolfram) | Ask Wolfram Alpha a question | +| [Exa Bulk Webset Items](block-integrations/exa/websets_items.md#exa-bulk-webset-items) | Get all items from a webset in bulk (with configurable limits) | +| [Exa Cancel Enrichment](block-integrations/exa/websets_enrichment.md#exa-cancel-enrichment) | Cancel a running enrichment operation | +| [Exa Cancel Webset](block-integrations/exa/websets.md#exa-cancel-webset) | Cancel all operations being performed on a Webset | +| [Exa Cancel Webset Search](block-integrations/exa/websets_search.md#exa-cancel-webset-search) | Cancel a running webset search | +| [Exa Contents](block-integrations/exa/contents.md#exa-contents) | Retrieves document contents using Exa's contents API | +| [Exa Create Monitor](block-integrations/exa/websets_monitor.md#exa-create-monitor) | Create automated monitors to keep websets updated with fresh data on a schedule | +| [Exa Create Or Find Webset](block-integrations/exa/websets.md#exa-create-or-find-webset) | Create a new webset or return existing one by external_id (idempotent operation) | +| [Exa Create Webset](block-integrations/exa/websets.md#exa-create-webset) | Create a new Exa Webset for persistent web search collections with optional waiting for initial results | +| [Exa Create Webset Search](block-integrations/exa/websets_search.md#exa-create-webset-search) | Add a new search to an existing webset to find more items | +| [Exa Delete Enrichment](block-integrations/exa/websets_enrichment.md#exa-delete-enrichment) | Delete an enrichment from a webset | +| [Exa Delete Monitor](block-integrations/exa/websets_monitor.md#exa-delete-monitor) | Delete a monitor from a webset | +| [Exa Delete Webset](block-integrations/exa/websets.md#exa-delete-webset) | Delete a Webset and all its items | +| [Exa Delete Webset Item](block-integrations/exa/websets_items.md#exa-delete-webset-item) | Delete a specific item from a webset | +| [Exa Find Or Create Search](block-integrations/exa/websets_search.md#exa-find-or-create-search) | Find existing search by query or create new - prevents duplicate searches in workflows | +| [Exa Find Similar](block-integrations/exa/similar.md#exa-find-similar) | Finds similar links using Exa's findSimilar API | +| [Exa Get Enrichment](block-integrations/exa/websets_enrichment.md#exa-get-enrichment) | Get the status and details of a webset enrichment | +| [Exa Get Monitor](block-integrations/exa/websets_monitor.md#exa-get-monitor) | Get the details and status of a webset monitor | +| [Exa Get Research](block-integrations/exa/research.md#exa-get-research) | Get status and results of a research task | +| [Exa Get Webset](block-integrations/exa/websets.md#exa-get-webset) | Retrieve a Webset by ID or external ID | +| [Exa Get Webset Item](block-integrations/exa/websets_items.md#exa-get-webset-item) | Get a specific item from a webset by its ID | +| [Exa Get Webset Search](block-integrations/exa/websets_search.md#exa-get-webset-search) | Get the status and details of a webset search | +| [Exa List Monitors](block-integrations/exa/websets_monitor.md#exa-list-monitors) | List all monitors with optional webset filtering | +| [Exa List Research](block-integrations/exa/research.md#exa-list-research) | List all research tasks with pagination support | +| [Exa List Webset Items](block-integrations/exa/websets_items.md#exa-list-webset-items) | List items in a webset with pagination support | +| [Exa List Websets](block-integrations/exa/websets.md#exa-list-websets) | List all Websets with pagination support | +| [Exa Preview Webset](block-integrations/exa/websets.md#exa-preview-webset) | Preview how a search query will be interpreted before creating a webset | +| [Exa Search](block-integrations/exa/search.md#exa-search) | Searches the web using Exa's advanced search API | +| [Exa Update Enrichment](block-integrations/exa/websets_enrichment.md#exa-update-enrichment) | Update an existing enrichment configuration | +| [Exa Update Monitor](block-integrations/exa/websets_monitor.md#exa-update-monitor) | Update a monitor's status, schedule, or metadata | +| [Exa Update Webset](block-integrations/exa/websets.md#exa-update-webset) | Update metadata for an existing Webset | +| [Exa Wait For Enrichment](block-integrations/exa/websets_polling.md#exa-wait-for-enrichment) | Wait for a webset enrichment to complete with progress tracking | +| [Exa Wait For Research](block-integrations/exa/research.md#exa-wait-for-research) | Wait for a research task to complete with configurable timeout | +| [Exa Wait For Search](block-integrations/exa/websets_polling.md#exa-wait-for-search) | Wait for a specific webset search to complete with progress tracking | +| [Exa Wait For Webset](block-integrations/exa/websets_polling.md#exa-wait-for-webset) | Wait for a webset to reach a specific status with progress tracking | +| [Exa Webset Items Summary](block-integrations/exa/websets_items.md#exa-webset-items-summary) | Get a summary of webset items without retrieving all data | +| [Exa Webset Status](block-integrations/exa/websets.md#exa-webset-status) | Get a quick status overview of a webset | +| [Exa Webset Summary](block-integrations/exa/websets.md#exa-webset-summary) | Get a comprehensive summary of a webset with samples and statistics | +| [Extract Website Content](block-integrations/jina/search.md#extract-website-content) | This block scrapes the content from the given web URL | +| [Fact Checker](block-integrations/jina/fact_checker.md#fact-checker) | This block checks the factuality of a given statement using Jina AI's Grounding API | +| [Firecrawl Crawl](block-integrations/firecrawl/crawl.md#firecrawl-crawl) | Firecrawl crawls websites to extract comprehensive data while bypassing blockers | +| [Firecrawl Extract](block-integrations/firecrawl/extract.md#firecrawl-extract) | Firecrawl crawls websites to extract comprehensive data while bypassing blockers | +| [Firecrawl Map Website](block-integrations/firecrawl/map.md#firecrawl-map-website) | Firecrawl maps a website to extract all the links | +| [Firecrawl Scrape](block-integrations/firecrawl/scrape.md#firecrawl-scrape) | Firecrawl scrapes a website to extract comprehensive data while bypassing blockers | +| [Firecrawl Search](block-integrations/firecrawl/search.md#firecrawl-search) | Firecrawl searches the web for the given query | +| [Get Person Detail](block-integrations/apollo/person.md#get-person-detail) | Get detailed person data with Apollo API, including email reveal | +| [Get Wikipedia Summary](block-integrations/search.md#get-wikipedia-summary) | This block fetches the summary of a given topic from Wikipedia | +| [Google Maps Search](block-integrations/search.md#google-maps-search) | This block searches for local businesses using Google Maps API | +| [Search Organizations](block-integrations/apollo/organization.md#search-organizations) | Search for organizations in Apollo | +| [Search People](block-integrations/apollo/people.md#search-people) | Search for people in Apollo | +| [Search The Web](block-integrations/jina/search.md#search-the-web) | This block searches the internet for the given search query | +| [Validate Emails](block-integrations/zerobounce/validate_emails.md#validate-emails) | Validate emails | ## Social Media and Content | Block Name | Description | |------------|-------------| -| [Create Discord Thread](discord/bot_blocks.md#create-discord-thread) | Creates a new thread in a Discord channel | -| [Create Reddit Post](misc.md#create-reddit-post) | Create a new post on a subreddit | -| [Delete Reddit Comment](misc.md#delete-reddit-comment) | Delete a Reddit comment that you own | -| [Delete Reddit Post](misc.md#delete-reddit-post) | Delete a Reddit post that you own | -| [Discord Channel Info](discord/bot_blocks.md#discord-channel-info) | Resolves Discord channel names to IDs and vice versa | -| [Discord Get Current User](discord/oauth_blocks.md#discord-get-current-user) | Gets information about the currently authenticated Discord user using OAuth2 credentials | -| [Discord User Info](discord/bot_blocks.md#discord-user-info) | Gets information about a Discord user by their ID | -| [Edit Reddit Post](misc.md#edit-reddit-post) | Edit the body text of an existing Reddit post that you own | -| [Get Linkedin Profile](enrichlayer/linkedin.md#get-linkedin-profile) | Fetch LinkedIn profile data using Enrichlayer | -| [Get Linkedin Profile Picture](enrichlayer/linkedin.md#get-linkedin-profile-picture) | Get LinkedIn profile pictures using Enrichlayer | -| [Get Reddit Comment](misc.md#get-reddit-comment) | Get details about a specific Reddit comment by its ID | -| [Get Reddit Comment Replies](misc.md#get-reddit-comment-replies) | Get replies to a specific Reddit comment | -| [Get Reddit Inbox](misc.md#get-reddit-inbox) | Get messages, mentions, and comment replies from your Reddit inbox | -| [Get Reddit Post](misc.md#get-reddit-post) | Get detailed information about a specific Reddit post by its ID | -| [Get Reddit Post Comments](misc.md#get-reddit-post-comments) | Get top-level comments on a Reddit post | -| [Get Reddit Posts](misc.md#get-reddit-posts) | This block fetches Reddit posts from a defined subreddit name | -| [Get Reddit User Info](misc.md#get-reddit-user-info) | Get information about a Reddit user including karma, account age, and verification status | -| [Get Subreddit Flairs](misc.md#get-subreddit-flairs) | Get available link flair options for a subreddit | -| [Get Subreddit Info](misc.md#get-subreddit-info) | Get information about a subreddit including subscriber count, description, and rules | -| [Get Subreddit Rules](misc.md#get-subreddit-rules) | Get the rules for a subreddit to ensure compliance before posting | -| [Get User Posts](misc.md#get-user-posts) | Fetch posts by a specific Reddit user | -| [Linkedin Person Lookup](enrichlayer/linkedin.md#linkedin-person-lookup) | Look up LinkedIn profiles by person information using Enrichlayer | -| [Linkedin Role Lookup](enrichlayer/linkedin.md#linkedin-role-lookup) | Look up LinkedIn profiles by role in a company using Enrichlayer | -| [Post Reddit Comment](misc.md#post-reddit-comment) | This block posts a Reddit comment on a specified Reddit post | -| [Post To Bluesky](ayrshare/post_to_bluesky.md#post-to-bluesky) | Post to Bluesky using Ayrshare | -| [Post To Facebook](ayrshare/post_to_facebook.md#post-to-facebook) | Post to Facebook using Ayrshare | -| [Post To GMB](ayrshare/post_to_gmb.md#post-to-gmb) | Post to Google My Business using Ayrshare | -| [Post To Instagram](ayrshare/post_to_instagram.md#post-to-instagram) | Post to Instagram using Ayrshare | -| [Post To Linked In](ayrshare/post_to_linkedin.md#post-to-linked-in) | Post to LinkedIn using Ayrshare | -| [Post To Pinterest](ayrshare/post_to_pinterest.md#post-to-pinterest) | Post to Pinterest using Ayrshare | -| [Post To Reddit](ayrshare/post_to_reddit.md#post-to-reddit) | Post to Reddit using Ayrshare | -| [Post To Snapchat](ayrshare/post_to_snapchat.md#post-to-snapchat) | Post to Snapchat using Ayrshare | -| [Post To Telegram](ayrshare/post_to_telegram.md#post-to-telegram) | Post to Telegram using Ayrshare | -| [Post To Threads](ayrshare/post_to_threads.md#post-to-threads) | Post to Threads using Ayrshare | -| [Post To Tik Tok](ayrshare/post_to_tiktok.md#post-to-tik-tok) | Post to TikTok using Ayrshare | -| [Post To X](ayrshare/post_to_x.md#post-to-x) | Post to X / Twitter using Ayrshare | -| [Post To You Tube](ayrshare/post_to_youtube.md#post-to-you-tube) | Post to YouTube using Ayrshare | -| [Publish To Medium](misc.md#publish-to-medium) | Publishes a post to Medium | -| [Read Discord Messages](discord/bot_blocks.md#read-discord-messages) | Reads messages from a Discord channel using a bot token | -| [Reddit Get My Posts](misc.md#reddit-get-my-posts) | Fetch posts created by the authenticated Reddit user (you) | -| [Reply To Discord Message](discord/bot_blocks.md#reply-to-discord-message) | Replies to a specific Discord message | -| [Reply To Reddit Comment](misc.md#reply-to-reddit-comment) | Reply to a specific Reddit comment | -| [Search Reddit](misc.md#search-reddit) | Search Reddit for posts matching a query | -| [Send Discord DM](discord/bot_blocks.md#send-discord-dm) | Sends a direct message to a Discord user using their user ID | -| [Send Discord Embed](discord/bot_blocks.md#send-discord-embed) | Sends a rich embed message to a Discord channel | -| [Send Discord File](discord/bot_blocks.md#send-discord-file) | Sends a file attachment to a Discord channel | -| [Send Discord Message](discord/bot_blocks.md#send-discord-message) | Sends a message to a Discord channel using a bot token | -| [Send Reddit Message](misc.md#send-reddit-message) | Send a private message (DM) to a Reddit user | -| [Transcribe Youtube Video](misc.md#transcribe-youtube-video) | Transcribes a YouTube video using a proxy | -| [Twitter Add List Member](twitter/list_members.md#twitter-add-list-member) | This block adds a specified user to a Twitter List owned by the authenticated user | -| [Twitter Bookmark Tweet](twitter/bookmark.md#twitter-bookmark-tweet) | This block bookmarks a tweet on Twitter | -| [Twitter Create List](twitter/manage_lists.md#twitter-create-list) | This block creates a new Twitter List for the authenticated user | -| [Twitter Delete List](twitter/manage_lists.md#twitter-delete-list) | This block deletes a specified Twitter List owned by the authenticated user | -| [Twitter Delete Tweet](twitter/manage.md#twitter-delete-tweet) | This block deletes a tweet on Twitter | -| [Twitter Follow List](twitter/list_follows.md#twitter-follow-list) | This block follows a specified Twitter list for the authenticated user | -| [Twitter Follow User](twitter/follows.md#twitter-follow-user) | This block follows a specified Twitter user | -| [Twitter Get Blocked Users](twitter/blocks.md#twitter-get-blocked-users) | This block retrieves a list of users blocked by the authenticating user | -| [Twitter Get Bookmarked Tweets](twitter/bookmark.md#twitter-get-bookmarked-tweets) | This block retrieves bookmarked tweets from Twitter | -| [Twitter Get Followers](twitter/follows.md#twitter-get-followers) | This block retrieves followers of a specified Twitter user | -| [Twitter Get Following](twitter/follows.md#twitter-get-following) | This block retrieves the users that a specified Twitter user is following | -| [Twitter Get Home Timeline](twitter/timeline.md#twitter-get-home-timeline) | This block retrieves the authenticated user's home timeline | -| [Twitter Get Liked Tweets](twitter/like.md#twitter-get-liked-tweets) | This block gets information about tweets liked by a user | -| [Twitter Get Liking Users](twitter/like.md#twitter-get-liking-users) | This block gets information about users who liked a tweet | -| [Twitter Get List](twitter/list_lookup.md#twitter-get-list) | This block retrieves information about a specified Twitter List | -| [Twitter Get List Members](twitter/list_members.md#twitter-get-list-members) | This block retrieves the members of a specified Twitter List | -| [Twitter Get List Memberships](twitter/list_members.md#twitter-get-list-memberships) | This block retrieves all Lists that a specified user is a member of | -| [Twitter Get List Tweets](twitter/list_tweets_lookup.md#twitter-get-list-tweets) | This block retrieves tweets from a specified Twitter list | -| [Twitter Get Muted Users](twitter/mutes.md#twitter-get-muted-users) | This block gets a list of users muted by the authenticating user | -| [Twitter Get Owned Lists](twitter/list_lookup.md#twitter-get-owned-lists) | This block retrieves all Lists owned by a specified Twitter user | -| [Twitter Get Pinned Lists](twitter/pinned_lists.md#twitter-get-pinned-lists) | This block returns the Lists pinned by the authenticated user | -| [Twitter Get Quote Tweets](twitter/quote.md#twitter-get-quote-tweets) | This block gets quote tweets for a specific tweet | -| [Twitter Get Retweeters](twitter/retweet.md#twitter-get-retweeters) | This block gets information about who has retweeted a tweet | -| [Twitter Get Space Buyers](twitter/spaces_lookup.md#twitter-get-space-buyers) | This block retrieves a list of users who purchased tickets to a Twitter Space | -| [Twitter Get Space By Id](twitter/spaces_lookup.md#twitter-get-space-by-id) | This block retrieves information about a single Twitter Space | -| [Twitter Get Space Tweets](twitter/spaces_lookup.md#twitter-get-space-tweets) | This block retrieves tweets shared in a Twitter Space | -| [Twitter Get Spaces](twitter/spaces_lookup.md#twitter-get-spaces) | This block retrieves information about multiple Twitter Spaces | -| [Twitter Get Tweet](twitter/tweet_lookup.md#twitter-get-tweet) | This block retrieves information about a specific Tweet | -| [Twitter Get Tweets](twitter/tweet_lookup.md#twitter-get-tweets) | This block retrieves information about multiple Tweets | -| [Twitter Get User](twitter/user_lookup.md#twitter-get-user) | This block retrieves information about a specified Twitter user | -| [Twitter Get User Mentions](twitter/timeline.md#twitter-get-user-mentions) | This block retrieves Tweets mentioning a specific user | -| [Twitter Get User Tweets](twitter/timeline.md#twitter-get-user-tweets) | This block retrieves Tweets composed by a single user | -| [Twitter Get Users](twitter/user_lookup.md#twitter-get-users) | This block retrieves information about multiple Twitter users | -| [Twitter Hide Reply](twitter/hide.md#twitter-hide-reply) | This block hides a reply to a tweet | -| [Twitter Like Tweet](twitter/like.md#twitter-like-tweet) | This block likes a tweet | -| [Twitter Mute User](twitter/mutes.md#twitter-mute-user) | This block mutes a specified Twitter user | -| [Twitter Pin List](twitter/pinned_lists.md#twitter-pin-list) | This block allows the authenticated user to pin a specified List | -| [Twitter Post Tweet](twitter/manage.md#twitter-post-tweet) | This block posts a tweet on Twitter | -| [Twitter Remove Bookmark Tweet](twitter/bookmark.md#twitter-remove-bookmark-tweet) | This block removes a bookmark from a tweet on Twitter | -| [Twitter Remove List Member](twitter/list_members.md#twitter-remove-list-member) | This block removes a specified user from a Twitter List owned by the authenticated user | -| [Twitter Remove Retweet](twitter/retweet.md#twitter-remove-retweet) | This block removes a retweet on Twitter | -| [Twitter Retweet](twitter/retweet.md#twitter-retweet) | This block retweets a tweet on Twitter | -| [Twitter Search Recent Tweets](twitter/manage.md#twitter-search-recent-tweets) | This block searches all public Tweets in Twitter history | -| [Twitter Search Spaces](twitter/search_spaces.md#twitter-search-spaces) | This block searches for Twitter Spaces based on specified terms | -| [Twitter Unfollow List](twitter/list_follows.md#twitter-unfollow-list) | This block unfollows a specified Twitter list for the authenticated user | -| [Twitter Unfollow User](twitter/follows.md#twitter-unfollow-user) | This block unfollows a specified Twitter user | -| [Twitter Unhide Reply](twitter/hide.md#twitter-unhide-reply) | This block unhides a reply to a tweet | -| [Twitter Unlike Tweet](twitter/like.md#twitter-unlike-tweet) | This block unlikes a tweet | -| [Twitter Unmute User](twitter/mutes.md#twitter-unmute-user) | This block unmutes a specified Twitter user | -| [Twitter Unpin List](twitter/pinned_lists.md#twitter-unpin-list) | This block allows the authenticated user to unpin a specified List | -| [Twitter Update List](twitter/manage_lists.md#twitter-update-list) | This block updates a specified Twitter List owned by the authenticated user | +| [Create Discord Thread](block-integrations/discord/bot_blocks.md#create-discord-thread) | Creates a new thread in a Discord channel | +| [Create Reddit Post](block-integrations/misc.md#create-reddit-post) | Create a new post on a subreddit | +| [Delete Reddit Comment](block-integrations/misc.md#delete-reddit-comment) | Delete a Reddit comment that you own | +| [Delete Reddit Post](block-integrations/misc.md#delete-reddit-post) | Delete a Reddit post that you own | +| [Discord Channel Info](block-integrations/discord/bot_blocks.md#discord-channel-info) | Resolves Discord channel names to IDs and vice versa | +| [Discord Get Current User](block-integrations/discord/oauth_blocks.md#discord-get-current-user) | Gets information about the currently authenticated Discord user using OAuth2 credentials | +| [Discord User Info](block-integrations/discord/bot_blocks.md#discord-user-info) | Gets information about a Discord user by their ID | +| [Edit Reddit Post](block-integrations/misc.md#edit-reddit-post) | Edit the body text of an existing Reddit post that you own | +| [Get Linkedin Profile](block-integrations/enrichlayer/linkedin.md#get-linkedin-profile) | Fetch LinkedIn profile data using Enrichlayer | +| [Get Linkedin Profile Picture](block-integrations/enrichlayer/linkedin.md#get-linkedin-profile-picture) | Get LinkedIn profile pictures using Enrichlayer | +| [Get Reddit Comment](block-integrations/misc.md#get-reddit-comment) | Get details about a specific Reddit comment by its ID | +| [Get Reddit Comment Replies](block-integrations/misc.md#get-reddit-comment-replies) | Get replies to a specific Reddit comment | +| [Get Reddit Inbox](block-integrations/misc.md#get-reddit-inbox) | Get messages, mentions, and comment replies from your Reddit inbox | +| [Get Reddit Post](block-integrations/misc.md#get-reddit-post) | Get detailed information about a specific Reddit post by its ID | +| [Get Reddit Post Comments](block-integrations/misc.md#get-reddit-post-comments) | Get top-level comments on a Reddit post | +| [Get Reddit Posts](block-integrations/misc.md#get-reddit-posts) | This block fetches Reddit posts from a defined subreddit name | +| [Get Reddit User Info](block-integrations/misc.md#get-reddit-user-info) | Get information about a Reddit user including karma, account age, and verification status | +| [Get Subreddit Flairs](block-integrations/misc.md#get-subreddit-flairs) | Get available link flair options for a subreddit | +| [Get Subreddit Info](block-integrations/misc.md#get-subreddit-info) | Get information about a subreddit including subscriber count, description, and rules | +| [Get Subreddit Rules](block-integrations/misc.md#get-subreddit-rules) | Get the rules for a subreddit to ensure compliance before posting | +| [Get User Posts](block-integrations/misc.md#get-user-posts) | Fetch posts by a specific Reddit user | +| [Linkedin Person Lookup](block-integrations/enrichlayer/linkedin.md#linkedin-person-lookup) | Look up LinkedIn profiles by person information using Enrichlayer | +| [Linkedin Role Lookup](block-integrations/enrichlayer/linkedin.md#linkedin-role-lookup) | Look up LinkedIn profiles by role in a company using Enrichlayer | +| [Post Reddit Comment](block-integrations/misc.md#post-reddit-comment) | This block posts a Reddit comment on a specified Reddit post | +| [Post To Bluesky](block-integrations/ayrshare/post_to_bluesky.md#post-to-bluesky) | Post to Bluesky using Ayrshare | +| [Post To Facebook](block-integrations/ayrshare/post_to_facebook.md#post-to-facebook) | Post to Facebook using Ayrshare | +| [Post To GMB](block-integrations/ayrshare/post_to_gmb.md#post-to-gmb) | Post to Google My Business using Ayrshare | +| [Post To Instagram](block-integrations/ayrshare/post_to_instagram.md#post-to-instagram) | Post to Instagram using Ayrshare | +| [Post To Linked In](block-integrations/ayrshare/post_to_linkedin.md#post-to-linked-in) | Post to LinkedIn using Ayrshare | +| [Post To Pinterest](block-integrations/ayrshare/post_to_pinterest.md#post-to-pinterest) | Post to Pinterest using Ayrshare | +| [Post To Reddit](block-integrations/ayrshare/post_to_reddit.md#post-to-reddit) | Post to Reddit using Ayrshare | +| [Post To Snapchat](block-integrations/ayrshare/post_to_snapchat.md#post-to-snapchat) | Post to Snapchat using Ayrshare | +| [Post To Telegram](block-integrations/ayrshare/post_to_telegram.md#post-to-telegram) | Post to Telegram using Ayrshare | +| [Post To Threads](block-integrations/ayrshare/post_to_threads.md#post-to-threads) | Post to Threads using Ayrshare | +| [Post To Tik Tok](block-integrations/ayrshare/post_to_tiktok.md#post-to-tik-tok) | Post to TikTok using Ayrshare | +| [Post To X](block-integrations/ayrshare/post_to_x.md#post-to-x) | Post to X / Twitter using Ayrshare | +| [Post To You Tube](block-integrations/ayrshare/post_to_youtube.md#post-to-you-tube) | Post to YouTube using Ayrshare | +| [Publish To Medium](block-integrations/misc.md#publish-to-medium) | Publishes a post to Medium | +| [Read Discord Messages](block-integrations/discord/bot_blocks.md#read-discord-messages) | Reads messages from a Discord channel using a bot token | +| [Reddit Get My Posts](block-integrations/misc.md#reddit-get-my-posts) | Fetch posts created by the authenticated Reddit user (you) | +| [Reply To Discord Message](block-integrations/discord/bot_blocks.md#reply-to-discord-message) | Replies to a specific Discord message | +| [Reply To Reddit Comment](block-integrations/misc.md#reply-to-reddit-comment) | Reply to a specific Reddit comment | +| [Search Reddit](block-integrations/misc.md#search-reddit) | Search Reddit for posts matching a query | +| [Send Discord DM](block-integrations/discord/bot_blocks.md#send-discord-dm) | Sends a direct message to a Discord user using their user ID | +| [Send Discord Embed](block-integrations/discord/bot_blocks.md#send-discord-embed) | Sends a rich embed message to a Discord channel | +| [Send Discord File](block-integrations/discord/bot_blocks.md#send-discord-file) | Sends a file attachment to a Discord channel | +| [Send Discord Message](block-integrations/discord/bot_blocks.md#send-discord-message) | Sends a message to a Discord channel using a bot token | +| [Send Reddit Message](block-integrations/misc.md#send-reddit-message) | Send a private message (DM) to a Reddit user | +| [Transcribe Youtube Video](block-integrations/misc.md#transcribe-youtube-video) | Transcribes a YouTube video using a proxy | +| [Twitter Add List Member](block-integrations/twitter/list_members.md#twitter-add-list-member) | This block adds a specified user to a Twitter List owned by the authenticated user | +| [Twitter Bookmark Tweet](block-integrations/twitter/bookmark.md#twitter-bookmark-tweet) | This block bookmarks a tweet on Twitter | +| [Twitter Create List](block-integrations/twitter/manage_lists.md#twitter-create-list) | This block creates a new Twitter List for the authenticated user | +| [Twitter Delete List](block-integrations/twitter/manage_lists.md#twitter-delete-list) | This block deletes a specified Twitter List owned by the authenticated user | +| [Twitter Delete Tweet](block-integrations/twitter/manage.md#twitter-delete-tweet) | This block deletes a tweet on Twitter | +| [Twitter Follow List](block-integrations/twitter/list_follows.md#twitter-follow-list) | This block follows a specified Twitter list for the authenticated user | +| [Twitter Follow User](block-integrations/twitter/follows.md#twitter-follow-user) | This block follows a specified Twitter user | +| [Twitter Get Blocked Users](block-integrations/twitter/blocks.md#twitter-get-blocked-users) | This block retrieves a list of users blocked by the authenticating user | +| [Twitter Get Bookmarked Tweets](block-integrations/twitter/bookmark.md#twitter-get-bookmarked-tweets) | This block retrieves bookmarked tweets from Twitter | +| [Twitter Get Followers](block-integrations/twitter/follows.md#twitter-get-followers) | This block retrieves followers of a specified Twitter user | +| [Twitter Get Following](block-integrations/twitter/follows.md#twitter-get-following) | This block retrieves the users that a specified Twitter user is following | +| [Twitter Get Home Timeline](block-integrations/twitter/timeline.md#twitter-get-home-timeline) | This block retrieves the authenticated user's home timeline | +| [Twitter Get Liked Tweets](block-integrations/twitter/like.md#twitter-get-liked-tweets) | This block gets information about tweets liked by a user | +| [Twitter Get Liking Users](block-integrations/twitter/like.md#twitter-get-liking-users) | This block gets information about users who liked a tweet | +| [Twitter Get List](block-integrations/twitter/list_lookup.md#twitter-get-list) | This block retrieves information about a specified Twitter List | +| [Twitter Get List Members](block-integrations/twitter/list_members.md#twitter-get-list-members) | This block retrieves the members of a specified Twitter List | +| [Twitter Get List Memberships](block-integrations/twitter/list_members.md#twitter-get-list-memberships) | This block retrieves all Lists that a specified user is a member of | +| [Twitter Get List Tweets](block-integrations/twitter/list_tweets_lookup.md#twitter-get-list-tweets) | This block retrieves tweets from a specified Twitter list | +| [Twitter Get Muted Users](block-integrations/twitter/mutes.md#twitter-get-muted-users) | This block gets a list of users muted by the authenticating user | +| [Twitter Get Owned Lists](block-integrations/twitter/list_lookup.md#twitter-get-owned-lists) | This block retrieves all Lists owned by a specified Twitter user | +| [Twitter Get Pinned Lists](block-integrations/twitter/pinned_lists.md#twitter-get-pinned-lists) | This block returns the Lists pinned by the authenticated user | +| [Twitter Get Quote Tweets](block-integrations/twitter/quote.md#twitter-get-quote-tweets) | This block gets quote tweets for a specific tweet | +| [Twitter Get Retweeters](block-integrations/twitter/retweet.md#twitter-get-retweeters) | This block gets information about who has retweeted a tweet | +| [Twitter Get Space Buyers](block-integrations/twitter/spaces_lookup.md#twitter-get-space-buyers) | This block retrieves a list of users who purchased tickets to a Twitter Space | +| [Twitter Get Space By Id](block-integrations/twitter/spaces_lookup.md#twitter-get-space-by-id) | This block retrieves information about a single Twitter Space | +| [Twitter Get Space Tweets](block-integrations/twitter/spaces_lookup.md#twitter-get-space-tweets) | This block retrieves tweets shared in a Twitter Space | +| [Twitter Get Spaces](block-integrations/twitter/spaces_lookup.md#twitter-get-spaces) | This block retrieves information about multiple Twitter Spaces | +| [Twitter Get Tweet](block-integrations/twitter/tweet_lookup.md#twitter-get-tweet) | This block retrieves information about a specific Tweet | +| [Twitter Get Tweets](block-integrations/twitter/tweet_lookup.md#twitter-get-tweets) | This block retrieves information about multiple Tweets | +| [Twitter Get User](block-integrations/twitter/user_lookup.md#twitter-get-user) | This block retrieves information about a specified Twitter user | +| [Twitter Get User Mentions](block-integrations/twitter/timeline.md#twitter-get-user-mentions) | This block retrieves Tweets mentioning a specific user | +| [Twitter Get User Tweets](block-integrations/twitter/timeline.md#twitter-get-user-tweets) | This block retrieves Tweets composed by a single user | +| [Twitter Get Users](block-integrations/twitter/user_lookup.md#twitter-get-users) | This block retrieves information about multiple Twitter users | +| [Twitter Hide Reply](block-integrations/twitter/hide.md#twitter-hide-reply) | This block hides a reply to a tweet | +| [Twitter Like Tweet](block-integrations/twitter/like.md#twitter-like-tweet) | This block likes a tweet | +| [Twitter Mute User](block-integrations/twitter/mutes.md#twitter-mute-user) | This block mutes a specified Twitter user | +| [Twitter Pin List](block-integrations/twitter/pinned_lists.md#twitter-pin-list) | This block allows the authenticated user to pin a specified List | +| [Twitter Post Tweet](block-integrations/twitter/manage.md#twitter-post-tweet) | This block posts a tweet on Twitter | +| [Twitter Remove Bookmark Tweet](block-integrations/twitter/bookmark.md#twitter-remove-bookmark-tweet) | This block removes a bookmark from a tweet on Twitter | +| [Twitter Remove List Member](block-integrations/twitter/list_members.md#twitter-remove-list-member) | This block removes a specified user from a Twitter List owned by the authenticated user | +| [Twitter Remove Retweet](block-integrations/twitter/retweet.md#twitter-remove-retweet) | This block removes a retweet on Twitter | +| [Twitter Retweet](block-integrations/twitter/retweet.md#twitter-retweet) | This block retweets a tweet on Twitter | +| [Twitter Search Recent Tweets](block-integrations/twitter/manage.md#twitter-search-recent-tweets) | This block searches all public Tweets in Twitter history | +| [Twitter Search Spaces](block-integrations/twitter/search_spaces.md#twitter-search-spaces) | This block searches for Twitter Spaces based on specified terms | +| [Twitter Unfollow List](block-integrations/twitter/list_follows.md#twitter-unfollow-list) | This block unfollows a specified Twitter list for the authenticated user | +| [Twitter Unfollow User](block-integrations/twitter/follows.md#twitter-unfollow-user) | This block unfollows a specified Twitter user | +| [Twitter Unhide Reply](block-integrations/twitter/hide.md#twitter-unhide-reply) | This block unhides a reply to a tweet | +| [Twitter Unlike Tweet](block-integrations/twitter/like.md#twitter-unlike-tweet) | This block unlikes a tweet | +| [Twitter Unmute User](block-integrations/twitter/mutes.md#twitter-unmute-user) | This block unmutes a specified Twitter user | +| [Twitter Unpin List](block-integrations/twitter/pinned_lists.md#twitter-unpin-list) | This block allows the authenticated user to unpin a specified List | +| [Twitter Update List](block-integrations/twitter/manage_lists.md#twitter-update-list) | This block updates a specified Twitter List owned by the authenticated user | ## Communication | Block Name | Description | |------------|-------------| -| [Baas Bot Join Meeting](baas/bots.md#baas-bot-join-meeting) | Deploy a bot to join and record a meeting | -| [Baas Bot Leave Meeting](baas/bots.md#baas-bot-leave-meeting) | Remove a bot from an ongoing meeting | -| [Gmail Add Label](google/gmail.md#gmail-add-label) | A block that adds a label to a specific email message in Gmail, creating the label if it doesn't exist | -| [Gmail Create Draft](google/gmail.md#gmail-create-draft) | Create draft emails in Gmail with automatic HTML detection and proper text formatting | -| [Gmail Draft Reply](google/gmail.md#gmail-draft-reply) | Create draft replies to Gmail threads with automatic HTML detection and proper text formatting | -| [Gmail Forward](google/gmail.md#gmail-forward) | Forward Gmail messages to other recipients with automatic HTML detection and proper formatting | -| [Gmail Get Profile](google/gmail.md#gmail-get-profile) | Get the authenticated user's Gmail profile details including email address and message statistics | -| [Gmail Get Thread](google/gmail.md#gmail-get-thread) | A block that retrieves an entire Gmail thread (email conversation) by ID, returning all messages with decoded bodies for reading complete conversations | -| [Gmail List Labels](google/gmail.md#gmail-list-labels) | A block that retrieves all labels (categories) from a Gmail account for organizing and categorizing emails | -| [Gmail Read](google/gmail.md#gmail-read) | A block that retrieves and reads emails from a Gmail account based on search criteria, returning detailed message information including subject, sender, body, and attachments | -| [Gmail Remove Label](google/gmail.md#gmail-remove-label) | A block that removes a label from a specific email message in a Gmail account | -| [Gmail Reply](google/gmail.md#gmail-reply) | Reply to Gmail threads with automatic HTML detection and proper text formatting | -| [Gmail Send](google/gmail.md#gmail-send) | Send emails via Gmail with automatic HTML detection and proper text formatting | -| [Hub Spot Engagement](hubspot/engagement.md#hub-spot-engagement) | Manages HubSpot engagements - sends emails and tracks engagement metrics | +| [Baas Bot Join Meeting](block-integrations/baas/bots.md#baas-bot-join-meeting) | Deploy a bot to join and record a meeting | +| [Baas Bot Leave Meeting](block-integrations/baas/bots.md#baas-bot-leave-meeting) | Remove a bot from an ongoing meeting | +| [Gmail Add Label](block-integrations/google/gmail.md#gmail-add-label) | A block that adds a label to a specific email message in Gmail, creating the label if it doesn't exist | +| [Gmail Create Draft](block-integrations/google/gmail.md#gmail-create-draft) | Create draft emails in Gmail with automatic HTML detection and proper text formatting | +| [Gmail Draft Reply](block-integrations/google/gmail.md#gmail-draft-reply) | Create draft replies to Gmail threads with automatic HTML detection and proper text formatting | +| [Gmail Forward](block-integrations/google/gmail.md#gmail-forward) | Forward Gmail messages to other recipients with automatic HTML detection and proper formatting | +| [Gmail Get Profile](block-integrations/google/gmail.md#gmail-get-profile) | Get the authenticated user's Gmail profile details including email address and message statistics | +| [Gmail Get Thread](block-integrations/google/gmail.md#gmail-get-thread) | A block that retrieves an entire Gmail thread (email conversation) by ID, returning all messages with decoded bodies for reading complete conversations | +| [Gmail List Labels](block-integrations/google/gmail.md#gmail-list-labels) | A block that retrieves all labels (categories) from a Gmail account for organizing and categorizing emails | +| [Gmail Read](block-integrations/google/gmail.md#gmail-read) | A block that retrieves and reads emails from a Gmail account based on search criteria, returning detailed message information including subject, sender, body, and attachments | +| [Gmail Remove Label](block-integrations/google/gmail.md#gmail-remove-label) | A block that removes a label from a specific email message in a Gmail account | +| [Gmail Reply](block-integrations/google/gmail.md#gmail-reply) | Reply to Gmail threads with automatic HTML detection and proper text formatting | +| [Gmail Send](block-integrations/google/gmail.md#gmail-send) | Send emails via Gmail with automatic HTML detection and proper text formatting | +| [Hub Spot Engagement](block-integrations/hubspot/engagement.md#hub-spot-engagement) | Manages HubSpot engagements - sends emails and tracks engagement metrics | ## Developer Tools | Block Name | Description | |------------|-------------| -| [Exa Code Context](exa/code_context.md#exa-code-context) | Search billions of GitHub repos, docs, and Stack Overflow for relevant code examples | -| [Execute Code](misc.md#execute-code) | Executes code in a sandbox environment with internet access | -| [Execute Code Step](misc.md#execute-code-step) | Execute code in a previously instantiated sandbox | -| [Github Add Label](github/issues.md#github-add-label) | A block that adds a label to a GitHub issue or pull request for categorization and organization | -| [Github Assign Issue](github/issues.md#github-assign-issue) | A block that assigns a GitHub user to an issue for task ownership and tracking | -| [Github Assign PR Reviewer](github/pull_requests.md#github-assign-pr-reviewer) | This block assigns a reviewer to a specified GitHub pull request | -| [Github Comment](github/issues.md#github-comment) | A block that posts comments on GitHub issues or pull requests using the GitHub API | -| [Github Create Check Run](github/checks.md#github-create-check-run) | Creates a new check run for a specific commit in a GitHub repository | -| [Github Create Comment Object](github/reviews.md#github-create-comment-object) | Creates a comment object for use with GitHub blocks | -| [Github Create File](github/repo.md#github-create-file) | This block creates a new file in a GitHub repository | -| [Github Create PR Review](github/reviews.md#github-create-pr-review) | This block creates a review on a GitHub pull request with optional inline comments | -| [Github Create Repository](github/repo.md#github-create-repository) | This block creates a new GitHub repository | -| [Github Create Status](github/statuses.md#github-create-status) | Creates a new commit status in a GitHub repository | -| [Github Delete Branch](github/repo.md#github-delete-branch) | This block deletes a specified branch | -| [Github Discussion Trigger](github/triggers.md#github-discussion-trigger) | This block triggers on GitHub Discussions events | -| [Github Get CI Results](github/ci.md#github-get-ci-results) | This block gets CI results for a commit or PR, with optional search for specific errors/warnings in logs | -| [Github Get PR Review Comments](github/reviews.md#github-get-pr-review-comments) | This block gets all review comments from a GitHub pull request or from a specific review | -| [Github Issues Trigger](github/triggers.md#github-issues-trigger) | This block triggers on GitHub issues events | -| [Github List Branches](github/repo.md#github-list-branches) | This block lists all branches for a specified GitHub repository | -| [Github List Comments](github/issues.md#github-list-comments) | A block that retrieves all comments from a GitHub issue or pull request, including comment metadata and content | -| [Github List Discussions](github/repo.md#github-list-discussions) | This block lists recent discussions for a specified GitHub repository | -| [Github List Issues](github/issues.md#github-list-issues) | A block that retrieves a list of issues from a GitHub repository with their titles and URLs | -| [Github List PR Reviewers](github/pull_requests.md#github-list-pr-reviewers) | This block lists all reviewers for a specified GitHub pull request | -| [Github List PR Reviews](github/reviews.md#github-list-pr-reviews) | This block lists all reviews for a specified GitHub pull request | -| [Github List Pull Requests](github/pull_requests.md#github-list-pull-requests) | This block lists all pull requests for a specified GitHub repository | -| [Github List Releases](github/repo.md#github-list-releases) | This block lists all releases for a specified GitHub repository | -| [Github List Stargazers](github/repo.md#github-list-stargazers) | This block lists all users who have starred a specified GitHub repository | -| [Github List Tags](github/repo.md#github-list-tags) | This block lists all tags for a specified GitHub repository | -| [Github Make Branch](github/repo.md#github-make-branch) | This block creates a new branch from a specified source branch | -| [Github Make Issue](github/issues.md#github-make-issue) | A block that creates new issues on GitHub repositories with a title and body content | -| [Github Make Pull Request](github/pull_requests.md#github-make-pull-request) | This block creates a new pull request on a specified GitHub repository | -| [Github Pull Request Trigger](github/triggers.md#github-pull-request-trigger) | This block triggers on pull request events and outputs the event type and payload | -| [Github Read File](github/repo.md#github-read-file) | This block reads the content of a specified file from a GitHub repository | -| [Github Read Folder](github/repo.md#github-read-folder) | This block reads the content of a specified folder from a GitHub repository | -| [Github Read Issue](github/issues.md#github-read-issue) | A block that retrieves information about a specific GitHub issue, including its title, body content, and creator | -| [Github Read Pull Request](github/pull_requests.md#github-read-pull-request) | This block reads the body, title, user, and changes of a specified GitHub pull request | -| [Github Release Trigger](github/triggers.md#github-release-trigger) | This block triggers on GitHub release events | -| [Github Remove Label](github/issues.md#github-remove-label) | A block that removes a label from a GitHub issue or pull request | -| [Github Resolve Review Discussion](github/reviews.md#github-resolve-review-discussion) | This block resolves or unresolves a review discussion thread on a GitHub pull request | -| [Github Star Trigger](github/triggers.md#github-star-trigger) | This block triggers on GitHub star events | -| [Github Submit Pending Review](github/reviews.md#github-submit-pending-review) | This block submits a pending (draft) review on a GitHub pull request | -| [Github Unassign Issue](github/issues.md#github-unassign-issue) | A block that removes a user's assignment from a GitHub issue | -| [Github Unassign PR Reviewer](github/pull_requests.md#github-unassign-pr-reviewer) | This block unassigns a reviewer from a specified GitHub pull request | -| [Github Update Check Run](github/checks.md#github-update-check-run) | Updates an existing check run in a GitHub repository | -| [Github Update Comment](github/issues.md#github-update-comment) | A block that updates an existing comment on a GitHub issue or pull request | -| [Github Update File](github/repo.md#github-update-file) | This block updates an existing file in a GitHub repository | -| [Instantiate Code Sandbox](misc.md#instantiate-code-sandbox) | Instantiate a sandbox environment with internet access in which you can execute code with the Execute Code Step block | -| [Slant3D Order Webhook](slant3d/webhook.md#slant3d-order-webhook) | This block triggers on Slant3D order status updates and outputs the event details, including tracking information when orders are shipped | +| [Exa Code Context](block-integrations/exa/code_context.md#exa-code-context) | Search billions of GitHub repos, docs, and Stack Overflow for relevant code examples | +| [Execute Code](block-integrations/misc.md#execute-code) | Executes code in a sandbox environment with internet access | +| [Execute Code Step](block-integrations/misc.md#execute-code-step) | Execute code in a previously instantiated sandbox | +| [Github Add Label](block-integrations/github/issues.md#github-add-label) | A block that adds a label to a GitHub issue or pull request for categorization and organization | +| [Github Assign Issue](block-integrations/github/issues.md#github-assign-issue) | A block that assigns a GitHub user to an issue for task ownership and tracking | +| [Github Assign PR Reviewer](block-integrations/github/pull_requests.md#github-assign-pr-reviewer) | This block assigns a reviewer to a specified GitHub pull request | +| [Github Comment](block-integrations/github/issues.md#github-comment) | A block that posts comments on GitHub issues or pull requests using the GitHub API | +| [Github Create Check Run](block-integrations/github/checks.md#github-create-check-run) | Creates a new check run for a specific commit in a GitHub repository | +| [Github Create Comment Object](block-integrations/github/reviews.md#github-create-comment-object) | Creates a comment object for use with GitHub blocks | +| [Github Create File](block-integrations/github/repo.md#github-create-file) | This block creates a new file in a GitHub repository | +| [Github Create PR Review](block-integrations/github/reviews.md#github-create-pr-review) | This block creates a review on a GitHub pull request with optional inline comments | +| [Github Create Repository](block-integrations/github/repo.md#github-create-repository) | This block creates a new GitHub repository | +| [Github Create Status](block-integrations/github/statuses.md#github-create-status) | Creates a new commit status in a GitHub repository | +| [Github Delete Branch](block-integrations/github/repo.md#github-delete-branch) | This block deletes a specified branch | +| [Github Discussion Trigger](block-integrations/github/triggers.md#github-discussion-trigger) | This block triggers on GitHub Discussions events | +| [Github Get CI Results](block-integrations/github/ci.md#github-get-ci-results) | This block gets CI results for a commit or PR, with optional search for specific errors/warnings in logs | +| [Github Get PR Review Comments](block-integrations/github/reviews.md#github-get-pr-review-comments) | This block gets all review comments from a GitHub pull request or from a specific review | +| [Github Issues Trigger](block-integrations/github/triggers.md#github-issues-trigger) | This block triggers on GitHub issues events | +| [Github List Branches](block-integrations/github/repo.md#github-list-branches) | This block lists all branches for a specified GitHub repository | +| [Github List Comments](block-integrations/github/issues.md#github-list-comments) | A block that retrieves all comments from a GitHub issue or pull request, including comment metadata and content | +| [Github List Discussions](block-integrations/github/repo.md#github-list-discussions) | This block lists recent discussions for a specified GitHub repository | +| [Github List Issues](block-integrations/github/issues.md#github-list-issues) | A block that retrieves a list of issues from a GitHub repository with their titles and URLs | +| [Github List PR Reviewers](block-integrations/github/pull_requests.md#github-list-pr-reviewers) | This block lists all reviewers for a specified GitHub pull request | +| [Github List PR Reviews](block-integrations/github/reviews.md#github-list-pr-reviews) | This block lists all reviews for a specified GitHub pull request | +| [Github List Pull Requests](block-integrations/github/pull_requests.md#github-list-pull-requests) | This block lists all pull requests for a specified GitHub repository | +| [Github List Releases](block-integrations/github/repo.md#github-list-releases) | This block lists all releases for a specified GitHub repository | +| [Github List Stargazers](block-integrations/github/repo.md#github-list-stargazers) | This block lists all users who have starred a specified GitHub repository | +| [Github List Tags](block-integrations/github/repo.md#github-list-tags) | This block lists all tags for a specified GitHub repository | +| [Github Make Branch](block-integrations/github/repo.md#github-make-branch) | This block creates a new branch from a specified source branch | +| [Github Make Issue](block-integrations/github/issues.md#github-make-issue) | A block that creates new issues on GitHub repositories with a title and body content | +| [Github Make Pull Request](block-integrations/github/pull_requests.md#github-make-pull-request) | This block creates a new pull request on a specified GitHub repository | +| [Github Pull Request Trigger](block-integrations/github/triggers.md#github-pull-request-trigger) | This block triggers on pull request events and outputs the event type and payload | +| [Github Read File](block-integrations/github/repo.md#github-read-file) | This block reads the content of a specified file from a GitHub repository | +| [Github Read Folder](block-integrations/github/repo.md#github-read-folder) | This block reads the content of a specified folder from a GitHub repository | +| [Github Read Issue](block-integrations/github/issues.md#github-read-issue) | A block that retrieves information about a specific GitHub issue, including its title, body content, and creator | +| [Github Read Pull Request](block-integrations/github/pull_requests.md#github-read-pull-request) | This block reads the body, title, user, and changes of a specified GitHub pull request | +| [Github Release Trigger](block-integrations/github/triggers.md#github-release-trigger) | This block triggers on GitHub release events | +| [Github Remove Label](block-integrations/github/issues.md#github-remove-label) | A block that removes a label from a GitHub issue or pull request | +| [Github Resolve Review Discussion](block-integrations/github/reviews.md#github-resolve-review-discussion) | This block resolves or unresolves a review discussion thread on a GitHub pull request | +| [Github Star Trigger](block-integrations/github/triggers.md#github-star-trigger) | This block triggers on GitHub star events | +| [Github Submit Pending Review](block-integrations/github/reviews.md#github-submit-pending-review) | This block submits a pending (draft) review on a GitHub pull request | +| [Github Unassign Issue](block-integrations/github/issues.md#github-unassign-issue) | A block that removes a user's assignment from a GitHub issue | +| [Github Unassign PR Reviewer](block-integrations/github/pull_requests.md#github-unassign-pr-reviewer) | This block unassigns a reviewer from a specified GitHub pull request | +| [Github Update Check Run](block-integrations/github/checks.md#github-update-check-run) | Updates an existing check run in a GitHub repository | +| [Github Update Comment](block-integrations/github/issues.md#github-update-comment) | A block that updates an existing comment on a GitHub issue or pull request | +| [Github Update File](block-integrations/github/repo.md#github-update-file) | This block updates an existing file in a GitHub repository | +| [Instantiate Code Sandbox](block-integrations/misc.md#instantiate-code-sandbox) | Instantiate a sandbox environment with internet access in which you can execute code with the Execute Code Step block | +| [Slant3D Order Webhook](block-integrations/slant3d/webhook.md#slant3d-order-webhook) | This block triggers on Slant3D order status updates and outputs the event details, including tracking information when orders are shipped | ## Media Generation | Block Name | Description | |------------|-------------| -| [Add Audio To Video](multimedia.md#add-audio-to-video) | Block to attach an audio file to a video file using moviepy | -| [Loop Video](multimedia.md#loop-video) | Block to loop a video to a given duration or number of repeats | -| [Media Duration](multimedia.md#media-duration) | Block to get the duration of a media file | +| [Add Audio To Video](block-integrations/multimedia.md#add-audio-to-video) | Block to attach an audio file to a video file using moviepy | +| [Loop Video](block-integrations/multimedia.md#loop-video) | Block to loop a video to a given duration or number of repeats | +| [Media Duration](block-integrations/multimedia.md#media-duration) | Block to get the duration of a media file | ## Productivity | Block Name | Description | |------------|-------------| -| [Google Calendar Create Event](google/calendar.md#google-calendar-create-event) | This block creates a new event in Google Calendar with customizable parameters | -| [Notion Create Page](notion/create_page.md#notion-create-page) | Create a new page in Notion | -| [Notion Read Database](notion/read_database.md#notion-read-database) | Query a Notion database with optional filtering and sorting, returning structured entries | -| [Notion Read Page](notion/read_page.md#notion-read-page) | Read a Notion page by its ID and return its raw JSON | -| [Notion Read Page Markdown](notion/read_page_markdown.md#notion-read-page-markdown) | Read a Notion page and convert it to Markdown format with proper formatting for headings, lists, links, and rich text | -| [Notion Search](notion/search.md#notion-search) | Search your Notion workspace for pages and databases by text query | -| [Todoist Close Task](todoist/tasks.md#todoist-close-task) | Closes a task in Todoist | -| [Todoist Create Comment](todoist/comments.md#todoist-create-comment) | Creates a new comment on a Todoist task or project | -| [Todoist Create Label](todoist/labels.md#todoist-create-label) | Creates a new label in Todoist, It will not work if same name already exists | -| [Todoist Create Project](todoist/projects.md#todoist-create-project) | Creates a new project in Todoist | -| [Todoist Create Task](todoist/tasks.md#todoist-create-task) | Creates a new task in a Todoist project | -| [Todoist Delete Comment](todoist/comments.md#todoist-delete-comment) | Deletes a Todoist comment | -| [Todoist Delete Label](todoist/labels.md#todoist-delete-label) | Deletes a personal label in Todoist | -| [Todoist Delete Project](todoist/projects.md#todoist-delete-project) | Deletes a Todoist project and all its contents | -| [Todoist Delete Section](todoist/sections.md#todoist-delete-section) | Deletes a section and all its tasks from Todoist | -| [Todoist Delete Task](todoist/tasks.md#todoist-delete-task) | Deletes a task in Todoist | -| [Todoist Get Comment](todoist/comments.md#todoist-get-comment) | Get a single comment from Todoist | -| [Todoist Get Comments](todoist/comments.md#todoist-get-comments) | Get all comments for a Todoist task or project | -| [Todoist Get Label](todoist/labels.md#todoist-get-label) | Gets a personal label from Todoist by ID | -| [Todoist Get Project](todoist/projects.md#todoist-get-project) | Gets details for a specific Todoist project | -| [Todoist Get Section](todoist/sections.md#todoist-get-section) | Gets a single section by ID from Todoist | -| [Todoist Get Shared Labels](todoist/labels.md#todoist-get-shared-labels) | Gets all shared labels from Todoist | -| [Todoist Get Task](todoist/tasks.md#todoist-get-task) | Get an active task from Todoist | -| [Todoist Get Tasks](todoist/tasks.md#todoist-get-tasks) | Get active tasks from Todoist | -| [Todoist List Collaborators](todoist/projects.md#todoist-list-collaborators) | Gets all collaborators for a specific Todoist project | -| [Todoist List Labels](todoist/labels.md#todoist-list-labels) | Gets all personal labels from Todoist | -| [Todoist List Projects](todoist/projects.md#todoist-list-projects) | Gets all projects and their details from Todoist | -| [Todoist List Sections](todoist/sections.md#todoist-list-sections) | Gets all sections and their details from Todoist | -| [Todoist Remove Shared Labels](todoist/labels.md#todoist-remove-shared-labels) | Removes all instances of a shared label | -| [Todoist Rename Shared Labels](todoist/labels.md#todoist-rename-shared-labels) | Renames all instances of a shared label | -| [Todoist Reopen Task](todoist/tasks.md#todoist-reopen-task) | Reopens a task in Todoist | -| [Todoist Update Comment](todoist/comments.md#todoist-update-comment) | Updates a Todoist comment | -| [Todoist Update Label](todoist/labels.md#todoist-update-label) | Updates a personal label in Todoist | -| [Todoist Update Project](todoist/projects.md#todoist-update-project) | Updates an existing project in Todoist | -| [Todoist Update Task](todoist/tasks.md#todoist-update-task) | Updates an existing task in Todoist | +| [Google Calendar Create Event](block-integrations/google/calendar.md#google-calendar-create-event) | This block creates a new event in Google Calendar with customizable parameters | +| [Notion Create Page](block-integrations/notion/create_page.md#notion-create-page) | Create a new page in Notion | +| [Notion Read Database](block-integrations/notion/read_database.md#notion-read-database) | Query a Notion database with optional filtering and sorting, returning structured entries | +| [Notion Read Page](block-integrations/notion/read_page.md#notion-read-page) | Read a Notion page by its ID and return its raw JSON | +| [Notion Read Page Markdown](block-integrations/notion/read_page_markdown.md#notion-read-page-markdown) | Read a Notion page and convert it to Markdown format with proper formatting for headings, lists, links, and rich text | +| [Notion Search](block-integrations/notion/search.md#notion-search) | Search your Notion workspace for pages and databases by text query | +| [Todoist Close Task](block-integrations/todoist/tasks.md#todoist-close-task) | Closes a task in Todoist | +| [Todoist Create Comment](block-integrations/todoist/comments.md#todoist-create-comment) | Creates a new comment on a Todoist task or project | +| [Todoist Create Label](block-integrations/todoist/labels.md#todoist-create-label) | Creates a new label in Todoist, It will not work if same name already exists | +| [Todoist Create Project](block-integrations/todoist/projects.md#todoist-create-project) | Creates a new project in Todoist | +| [Todoist Create Task](block-integrations/todoist/tasks.md#todoist-create-task) | Creates a new task in a Todoist project | +| [Todoist Delete Comment](block-integrations/todoist/comments.md#todoist-delete-comment) | Deletes a Todoist comment | +| [Todoist Delete Label](block-integrations/todoist/labels.md#todoist-delete-label) | Deletes a personal label in Todoist | +| [Todoist Delete Project](block-integrations/todoist/projects.md#todoist-delete-project) | Deletes a Todoist project and all its contents | +| [Todoist Delete Section](block-integrations/todoist/sections.md#todoist-delete-section) | Deletes a section and all its tasks from Todoist | +| [Todoist Delete Task](block-integrations/todoist/tasks.md#todoist-delete-task) | Deletes a task in Todoist | +| [Todoist Get Comment](block-integrations/todoist/comments.md#todoist-get-comment) | Get a single comment from Todoist | +| [Todoist Get Comments](block-integrations/todoist/comments.md#todoist-get-comments) | Get all comments for a Todoist task or project | +| [Todoist Get Label](block-integrations/todoist/labels.md#todoist-get-label) | Gets a personal label from Todoist by ID | +| [Todoist Get Project](block-integrations/todoist/projects.md#todoist-get-project) | Gets details for a specific Todoist project | +| [Todoist Get Section](block-integrations/todoist/sections.md#todoist-get-section) | Gets a single section by ID from Todoist | +| [Todoist Get Shared Labels](block-integrations/todoist/labels.md#todoist-get-shared-labels) | Gets all shared labels from Todoist | +| [Todoist Get Task](block-integrations/todoist/tasks.md#todoist-get-task) | Get an active task from Todoist | +| [Todoist Get Tasks](block-integrations/todoist/tasks.md#todoist-get-tasks) | Get active tasks from Todoist | +| [Todoist List Collaborators](block-integrations/todoist/projects.md#todoist-list-collaborators) | Gets all collaborators for a specific Todoist project | +| [Todoist List Labels](block-integrations/todoist/labels.md#todoist-list-labels) | Gets all personal labels from Todoist | +| [Todoist List Projects](block-integrations/todoist/projects.md#todoist-list-projects) | Gets all projects and their details from Todoist | +| [Todoist List Sections](block-integrations/todoist/sections.md#todoist-list-sections) | Gets all sections and their details from Todoist | +| [Todoist Remove Shared Labels](block-integrations/todoist/labels.md#todoist-remove-shared-labels) | Removes all instances of a shared label | +| [Todoist Rename Shared Labels](block-integrations/todoist/labels.md#todoist-rename-shared-labels) | Renames all instances of a shared label | +| [Todoist Reopen Task](block-integrations/todoist/tasks.md#todoist-reopen-task) | Reopens a task in Todoist | +| [Todoist Update Comment](block-integrations/todoist/comments.md#todoist-update-comment) | Updates a Todoist comment | +| [Todoist Update Label](block-integrations/todoist/labels.md#todoist-update-label) | Updates a personal label in Todoist | +| [Todoist Update Project](block-integrations/todoist/projects.md#todoist-update-project) | Updates an existing project in Todoist | +| [Todoist Update Task](block-integrations/todoist/tasks.md#todoist-update-task) | Updates an existing task in Todoist | ## Logic and Control Flow | Block Name | Description | |------------|-------------| -| [Calculator](logic.md#calculator) | Performs a mathematical operation on two numbers | -| [Condition](logic.md#condition) | Handles conditional logic based on comparison operators | -| [Count Items](logic.md#count-items) | Counts the number of items in a collection | -| [Data Sampling](logic.md#data-sampling) | This block samples data from a given dataset using various sampling methods | -| [Exa Webset Ready Check](exa/websets.md#exa-webset-ready-check) | Check if webset is ready for next operation - enables conditional workflow branching | -| [If Input Matches](logic.md#if-input-matches) | Handles conditional logic based on comparison operators | -| [Pinecone Init](logic.md#pinecone-init) | Initializes a Pinecone index | -| [Pinecone Insert](logic.md#pinecone-insert) | Upload data to a Pinecone index | -| [Pinecone Query](logic.md#pinecone-query) | Queries a Pinecone index | -| [Step Through Items](logic.md#step-through-items) | Iterates over a list or dictionary and outputs each item | +| [Calculator](block-integrations/logic.md#calculator) | Performs a mathematical operation on two numbers | +| [Condition](block-integrations/logic.md#condition) | Handles conditional logic based on comparison operators | +| [Count Items](block-integrations/logic.md#count-items) | Counts the number of items in a collection | +| [Data Sampling](block-integrations/logic.md#data-sampling) | This block samples data from a given dataset using various sampling methods | +| [Exa Webset Ready Check](block-integrations/exa/websets.md#exa-webset-ready-check) | Check if webset is ready for next operation - enables conditional workflow branching | +| [If Input Matches](block-integrations/logic.md#if-input-matches) | Handles conditional logic based on comparison operators | +| [Pinecone Init](block-integrations/logic.md#pinecone-init) | Initializes a Pinecone index | +| [Pinecone Insert](block-integrations/logic.md#pinecone-insert) | Upload data to a Pinecone index | +| [Pinecone Query](block-integrations/logic.md#pinecone-query) | Queries a Pinecone index | +| [Step Through Items](block-integrations/logic.md#step-through-items) | Iterates over a list or dictionary and outputs each item | ## Input/Output | Block Name | Description | |------------|-------------| -| [Exa Webset Webhook](exa/webhook_blocks.md#exa-webset-webhook) | Receive webhook notifications for Exa webset events | -| [Generic Webhook Trigger](generic_webhook/triggers.md#generic-webhook-trigger) | This block will output the contents of the generic input for the webhook | -| [Read RSS Feed](misc.md#read-rss-feed) | Reads RSS feed entries from a given URL | -| [Send Authenticated Web Request](misc.md#send-authenticated-web-request) | Make an authenticated HTTP request with host-scoped credentials (JSON / form / multipart) | -| [Send Email](misc.md#send-email) | This block sends an email using the provided SMTP credentials | -| [Send Web Request](misc.md#send-web-request) | Make an HTTP request (JSON / form / multipart) | +| [Exa Webset Webhook](block-integrations/exa/webhook_blocks.md#exa-webset-webhook) | Receive webhook notifications for Exa webset events | +| [Generic Webhook Trigger](block-integrations/generic_webhook/triggers.md#generic-webhook-trigger) | This block will output the contents of the generic input for the webhook | +| [Read RSS Feed](block-integrations/misc.md#read-rss-feed) | Reads RSS feed entries from a given URL | +| [Send Authenticated Web Request](block-integrations/misc.md#send-authenticated-web-request) | Make an authenticated HTTP request with host-scoped credentials (JSON / form / multipart) | +| [Send Email](block-integrations/misc.md#send-email) | This block sends an email using the provided SMTP credentials | +| [Send Web Request](block-integrations/misc.md#send-web-request) | Make an HTTP request (JSON / form / multipart) | ## Agent Integration | Block Name | Description | |------------|-------------| -| [Agent Executor](misc.md#agent-executor) | Executes an existing agent inside your agent | +| [Agent Executor](block-integrations/misc.md#agent-executor) | Executes an existing agent inside your agent | ## CRM Services | Block Name | Description | |------------|-------------| -| [Add Lead To Campaign](smartlead/campaign.md#add-lead-to-campaign) | Add a lead to a campaign in SmartLead | -| [Create Campaign](smartlead/campaign.md#create-campaign) | Create a campaign in SmartLead | -| [Hub Spot Company](hubspot/company.md#hub-spot-company) | Manages HubSpot companies - create, update, and retrieve company information | -| [Hub Spot Contact](hubspot/contact.md#hub-spot-contact) | Manages HubSpot contacts - create, update, and retrieve contact information | -| [Save Campaign Sequences](smartlead/campaign.md#save-campaign-sequences) | Save sequences within a campaign | +| [Add Lead To Campaign](block-integrations/smartlead/campaign.md#add-lead-to-campaign) | Add a lead to a campaign in SmartLead | +| [Create Campaign](block-integrations/smartlead/campaign.md#create-campaign) | Create a campaign in SmartLead | +| [Hub Spot Company](block-integrations/hubspot/company.md#hub-spot-company) | Manages HubSpot companies - create, update, and retrieve company information | +| [Hub Spot Contact](block-integrations/hubspot/contact.md#hub-spot-contact) | Manages HubSpot contacts - create, update, and retrieve contact information | +| [Save Campaign Sequences](block-integrations/smartlead/campaign.md#save-campaign-sequences) | Save sequences within a campaign | ## AI Safety | Block Name | Description | |------------|-------------| -| [Nvidia Deepfake Detect](nvidia/deepfake.md#nvidia-deepfake-detect) | Detects potential deepfakes in images using Nvidia's AI API | +| [Nvidia Deepfake Detect](block-integrations/nvidia/deepfake.md#nvidia-deepfake-detect) | Detects potential deepfakes in images using Nvidia's AI API | ## Issue Tracking | Block Name | Description | |------------|-------------| -| [Linear Create Comment](linear/comment.md#linear-create-comment) | Creates a new comment on a Linear issue | -| [Linear Create Issue](linear/issues.md#linear-create-issue) | Creates a new issue on Linear | -| [Linear Get Project Issues](linear/issues.md#linear-get-project-issues) | Gets issues from a Linear project filtered by status and assignee | -| [Linear Search Projects](linear/projects.md#linear-search-projects) | Searches for projects on Linear | +| [Linear Create Comment](block-integrations/linear/comment.md#linear-create-comment) | Creates a new comment on a Linear issue | +| [Linear Create Issue](block-integrations/linear/issues.md#linear-create-issue) | Creates a new issue on Linear | +| [Linear Get Project Issues](block-integrations/linear/issues.md#linear-get-project-issues) | Gets issues from a Linear project filtered by status and assignee | +| [Linear Search Projects](block-integrations/linear/projects.md#linear-search-projects) | Searches for projects on Linear | ## Hardware | Block Name | Description | |------------|-------------| -| [Compass AI Trigger](compass/triggers.md#compass-ai-trigger) | This block will output the contents of the compass transcription | +| [Compass AI Trigger](block-integrations/compass/triggers.md#compass-ai-trigger) | This block will output the contents of the compass transcription | diff --git a/docs/integrations/SUMMARY.md b/docs/integrations/SUMMARY.md new file mode 100644 index 0000000000..9ce440ca45 --- /dev/null +++ b/docs/integrations/SUMMARY.md @@ -0,0 +1,133 @@ +# Table of contents + +* [AutoGPT Blocks Overview](README.md) + +## Guides + +* [LLM Providers](guides/llm-providers.md) +* [Voice Providers](guides/voice-providers.md) + +## Block Integrations + +* [Airtable Bases](block-integrations/airtable/bases.md) +* [Airtable Records](block-integrations/airtable/records.md) +* [Airtable Schema](block-integrations/airtable/schema.md) +* [Airtable Triggers](block-integrations/airtable/triggers.md) +* [Apollo Organization](block-integrations/apollo/organization.md) +* [Apollo People](block-integrations/apollo/people.md) +* [Apollo Person](block-integrations/apollo/person.md) +* [Ayrshare Post To Bluesky](block-integrations/ayrshare/post_to_bluesky.md) +* [Ayrshare Post To Facebook](block-integrations/ayrshare/post_to_facebook.md) +* [Ayrshare Post To GMB](block-integrations/ayrshare/post_to_gmb.md) +* [Ayrshare Post To Instagram](block-integrations/ayrshare/post_to_instagram.md) +* [Ayrshare Post To LinkedIn](block-integrations/ayrshare/post_to_linkedin.md) +* [Ayrshare Post To Pinterest](block-integrations/ayrshare/post_to_pinterest.md) +* [Ayrshare Post To Reddit](block-integrations/ayrshare/post_to_reddit.md) +* [Ayrshare Post To Snapchat](block-integrations/ayrshare/post_to_snapchat.md) +* [Ayrshare Post To Telegram](block-integrations/ayrshare/post_to_telegram.md) +* [Ayrshare Post To Threads](block-integrations/ayrshare/post_to_threads.md) +* [Ayrshare Post To TikTok](block-integrations/ayrshare/post_to_tiktok.md) +* [Ayrshare Post To X](block-integrations/ayrshare/post_to_x.md) +* [Ayrshare Post To YouTube](block-integrations/ayrshare/post_to_youtube.md) +* [Baas Bots](block-integrations/baas/bots.md) +* [Bannerbear Text Overlay](block-integrations/bannerbear/text_overlay.md) +* [Basic](block-integrations/basic.md) +* [Compass Triggers](block-integrations/compass/triggers.md) +* [Data](block-integrations/data.md) +* [Dataforseo Keyword Suggestions](block-integrations/dataforseo/keyword_suggestions.md) +* [Dataforseo Related Keywords](block-integrations/dataforseo/related_keywords.md) +* [Discord Bot Blocks](block-integrations/discord/bot_blocks.md) +* [Discord OAuth Blocks](block-integrations/discord/oauth_blocks.md) +* [Enrichlayer LinkedIn](block-integrations/enrichlayer/linkedin.md) +* [Exa Answers](block-integrations/exa/answers.md) +* [Exa Code Context](block-integrations/exa/code_context.md) +* [Exa Contents](block-integrations/exa/contents.md) +* [Exa Research](block-integrations/exa/research.md) +* [Exa Search](block-integrations/exa/search.md) +* [Exa Similar](block-integrations/exa/similar.md) +* [Exa Webhook Blocks](block-integrations/exa/webhook_blocks.md) +* [Exa Websets](block-integrations/exa/websets.md) +* [Exa Websets Enrichment](block-integrations/exa/websets_enrichment.md) +* [Exa Websets Import Export](block-integrations/exa/websets_import_export.md) +* [Exa Websets Items](block-integrations/exa/websets_items.md) +* [Exa Websets Monitor](block-integrations/exa/websets_monitor.md) +* [Exa Websets Polling](block-integrations/exa/websets_polling.md) +* [Exa Websets Search](block-integrations/exa/websets_search.md) +* [Fal AI Video Generator](block-integrations/fal/ai_video_generator.md) +* [Firecrawl Crawl](block-integrations/firecrawl/crawl.md) +* [Firecrawl Extract](block-integrations/firecrawl/extract.md) +* [Firecrawl Map](block-integrations/firecrawl/map.md) +* [Firecrawl Scrape](block-integrations/firecrawl/scrape.md) +* [Firecrawl Search](block-integrations/firecrawl/search.md) +* [Generic Webhook Triggers](block-integrations/generic_webhook/triggers.md) +* [GitHub Checks](block-integrations/github/checks.md) +* [GitHub CI](block-integrations/github/ci.md) +* [GitHub Issues](block-integrations/github/issues.md) +* [GitHub Pull Requests](block-integrations/github/pull_requests.md) +* [GitHub Repo](block-integrations/github/repo.md) +* [GitHub Reviews](block-integrations/github/reviews.md) +* [GitHub Statuses](block-integrations/github/statuses.md) +* [GitHub Triggers](block-integrations/github/triggers.md) +* [Google Calendar](block-integrations/google/calendar.md) +* [Google Docs](block-integrations/google/docs.md) +* [Google Gmail](block-integrations/google/gmail.md) +* [Google Sheets](block-integrations/google/sheets.md) +* [HubSpot Company](block-integrations/hubspot/company.md) +* [HubSpot Contact](block-integrations/hubspot/contact.md) +* [HubSpot Engagement](block-integrations/hubspot/engagement.md) +* [Jina Chunking](block-integrations/jina/chunking.md) +* [Jina Embeddings](block-integrations/jina/embeddings.md) +* [Jina Fact Checker](block-integrations/jina/fact_checker.md) +* [Jina Search](block-integrations/jina/search.md) +* [Linear Comment](block-integrations/linear/comment.md) +* [Linear Issues](block-integrations/linear/issues.md) +* [Linear Projects](block-integrations/linear/projects.md) +* [LLM](block-integrations/llm.md) +* [Logic](block-integrations/logic.md) +* [Misc](block-integrations/misc.md) +* [Multimedia](block-integrations/multimedia.md) +* [Notion Create Page](block-integrations/notion/create_page.md) +* [Notion Read Database](block-integrations/notion/read_database.md) +* [Notion Read Page](block-integrations/notion/read_page.md) +* [Notion Read Page Markdown](block-integrations/notion/read_page_markdown.md) +* [Notion Search](block-integrations/notion/search.md) +* [Nvidia Deepfake](block-integrations/nvidia/deepfake.md) +* [Replicate Flux Advanced](block-integrations/replicate/flux_advanced.md) +* [Replicate Replicate Block](block-integrations/replicate/replicate_block.md) +* [Search](block-integrations/search.md) +* [Slant3D Filament](block-integrations/slant3d/filament.md) +* [Slant3D Order](block-integrations/slant3d/order.md) +* [Slant3D Slicing](block-integrations/slant3d/slicing.md) +* [Slant3D Webhook](block-integrations/slant3d/webhook.md) +* [Smartlead Campaign](block-integrations/smartlead/campaign.md) +* [Stagehand Blocks](block-integrations/stagehand/blocks.md) +* [System Library Operations](block-integrations/system/library_operations.md) +* [System Store Operations](block-integrations/system/store_operations.md) +* [Text](block-integrations/text.md) +* [Todoist Comments](block-integrations/todoist/comments.md) +* [Todoist Labels](block-integrations/todoist/labels.md) +* [Todoist Projects](block-integrations/todoist/projects.md) +* [Todoist Sections](block-integrations/todoist/sections.md) +* [Todoist Tasks](block-integrations/todoist/tasks.md) +* [Twitter Blocks](block-integrations/twitter/blocks.md) +* [Twitter Bookmark](block-integrations/twitter/bookmark.md) +* [Twitter Follows](block-integrations/twitter/follows.md) +* [Twitter Hide](block-integrations/twitter/hide.md) +* [Twitter Like](block-integrations/twitter/like.md) +* [Twitter List Follows](block-integrations/twitter/list_follows.md) +* [Twitter List Lookup](block-integrations/twitter/list_lookup.md) +* [Twitter List Members](block-integrations/twitter/list_members.md) +* [Twitter List Tweets Lookup](block-integrations/twitter/list_tweets_lookup.md) +* [Twitter Manage](block-integrations/twitter/manage.md) +* [Twitter Manage Lists](block-integrations/twitter/manage_lists.md) +* [Twitter Mutes](block-integrations/twitter/mutes.md) +* [Twitter Pinned Lists](block-integrations/twitter/pinned_lists.md) +* [Twitter Quote](block-integrations/twitter/quote.md) +* [Twitter Retweet](block-integrations/twitter/retweet.md) +* [Twitter Search Spaces](block-integrations/twitter/search_spaces.md) +* [Twitter Spaces Lookup](block-integrations/twitter/spaces_lookup.md) +* [Twitter Timeline](block-integrations/twitter/timeline.md) +* [Twitter Tweet Lookup](block-integrations/twitter/tweet_lookup.md) +* [Twitter User Lookup](block-integrations/twitter/user_lookup.md) +* [Wolfram LLM API](block-integrations/wolfram/llm_api.md) +* [Zerobounce Validate Emails](block-integrations/zerobounce/validate_emails.md) diff --git a/docs/integrations/ai_condition.md b/docs/integrations/block-integrations/ai_condition.md similarity index 100% rename from docs/integrations/ai_condition.md rename to docs/integrations/block-integrations/ai_condition.md diff --git a/docs/integrations/ai_shortform_video_block.md b/docs/integrations/block-integrations/ai_shortform_video_block.md similarity index 100% rename from docs/integrations/ai_shortform_video_block.md rename to docs/integrations/block-integrations/ai_shortform_video_block.md diff --git a/docs/integrations/airtable/bases.md b/docs/integrations/block-integrations/airtable/bases.md similarity index 100% rename from docs/integrations/airtable/bases.md rename to docs/integrations/block-integrations/airtable/bases.md diff --git a/docs/integrations/airtable/records.md b/docs/integrations/block-integrations/airtable/records.md similarity index 100% rename from docs/integrations/airtable/records.md rename to docs/integrations/block-integrations/airtable/records.md diff --git a/docs/integrations/airtable/schema.md b/docs/integrations/block-integrations/airtable/schema.md similarity index 100% rename from docs/integrations/airtable/schema.md rename to docs/integrations/block-integrations/airtable/schema.md diff --git a/docs/integrations/airtable/triggers.md b/docs/integrations/block-integrations/airtable/triggers.md similarity index 100% rename from docs/integrations/airtable/triggers.md rename to docs/integrations/block-integrations/airtable/triggers.md diff --git a/docs/integrations/apollo/organization.md b/docs/integrations/block-integrations/apollo/organization.md similarity index 100% rename from docs/integrations/apollo/organization.md rename to docs/integrations/block-integrations/apollo/organization.md diff --git a/docs/integrations/apollo/people.md b/docs/integrations/block-integrations/apollo/people.md similarity index 100% rename from docs/integrations/apollo/people.md rename to docs/integrations/block-integrations/apollo/people.md diff --git a/docs/integrations/apollo/person.md b/docs/integrations/block-integrations/apollo/person.md similarity index 100% rename from docs/integrations/apollo/person.md rename to docs/integrations/block-integrations/apollo/person.md diff --git a/docs/integrations/ayrshare/post_to_bluesky.md b/docs/integrations/block-integrations/ayrshare/post_to_bluesky.md similarity index 100% rename from docs/integrations/ayrshare/post_to_bluesky.md rename to docs/integrations/block-integrations/ayrshare/post_to_bluesky.md diff --git a/docs/integrations/ayrshare/post_to_facebook.md b/docs/integrations/block-integrations/ayrshare/post_to_facebook.md similarity index 100% rename from docs/integrations/ayrshare/post_to_facebook.md rename to docs/integrations/block-integrations/ayrshare/post_to_facebook.md diff --git a/docs/integrations/ayrshare/post_to_gmb.md b/docs/integrations/block-integrations/ayrshare/post_to_gmb.md similarity index 100% rename from docs/integrations/ayrshare/post_to_gmb.md rename to docs/integrations/block-integrations/ayrshare/post_to_gmb.md diff --git a/docs/integrations/ayrshare/post_to_instagram.md b/docs/integrations/block-integrations/ayrshare/post_to_instagram.md similarity index 100% rename from docs/integrations/ayrshare/post_to_instagram.md rename to docs/integrations/block-integrations/ayrshare/post_to_instagram.md diff --git a/docs/integrations/ayrshare/post_to_linkedin.md b/docs/integrations/block-integrations/ayrshare/post_to_linkedin.md similarity index 100% rename from docs/integrations/ayrshare/post_to_linkedin.md rename to docs/integrations/block-integrations/ayrshare/post_to_linkedin.md diff --git a/docs/integrations/ayrshare/post_to_pinterest.md b/docs/integrations/block-integrations/ayrshare/post_to_pinterest.md similarity index 100% rename from docs/integrations/ayrshare/post_to_pinterest.md rename to docs/integrations/block-integrations/ayrshare/post_to_pinterest.md diff --git a/docs/integrations/ayrshare/post_to_reddit.md b/docs/integrations/block-integrations/ayrshare/post_to_reddit.md similarity index 100% rename from docs/integrations/ayrshare/post_to_reddit.md rename to docs/integrations/block-integrations/ayrshare/post_to_reddit.md diff --git a/docs/integrations/ayrshare/post_to_snapchat.md b/docs/integrations/block-integrations/ayrshare/post_to_snapchat.md similarity index 100% rename from docs/integrations/ayrshare/post_to_snapchat.md rename to docs/integrations/block-integrations/ayrshare/post_to_snapchat.md diff --git a/docs/integrations/ayrshare/post_to_telegram.md b/docs/integrations/block-integrations/ayrshare/post_to_telegram.md similarity index 100% rename from docs/integrations/ayrshare/post_to_telegram.md rename to docs/integrations/block-integrations/ayrshare/post_to_telegram.md diff --git a/docs/integrations/ayrshare/post_to_threads.md b/docs/integrations/block-integrations/ayrshare/post_to_threads.md similarity index 100% rename from docs/integrations/ayrshare/post_to_threads.md rename to docs/integrations/block-integrations/ayrshare/post_to_threads.md diff --git a/docs/integrations/ayrshare/post_to_tiktok.md b/docs/integrations/block-integrations/ayrshare/post_to_tiktok.md similarity index 100% rename from docs/integrations/ayrshare/post_to_tiktok.md rename to docs/integrations/block-integrations/ayrshare/post_to_tiktok.md diff --git a/docs/integrations/ayrshare/post_to_x.md b/docs/integrations/block-integrations/ayrshare/post_to_x.md similarity index 100% rename from docs/integrations/ayrshare/post_to_x.md rename to docs/integrations/block-integrations/ayrshare/post_to_x.md diff --git a/docs/integrations/ayrshare/post_to_youtube.md b/docs/integrations/block-integrations/ayrshare/post_to_youtube.md similarity index 100% rename from docs/integrations/ayrshare/post_to_youtube.md rename to docs/integrations/block-integrations/ayrshare/post_to_youtube.md diff --git a/docs/integrations/baas/bots.md b/docs/integrations/block-integrations/baas/bots.md similarity index 100% rename from docs/integrations/baas/bots.md rename to docs/integrations/block-integrations/baas/bots.md diff --git a/docs/integrations/bannerbear/text_overlay.md b/docs/integrations/block-integrations/bannerbear/text_overlay.md similarity index 100% rename from docs/integrations/bannerbear/text_overlay.md rename to docs/integrations/block-integrations/bannerbear/text_overlay.md diff --git a/docs/integrations/basic.md b/docs/integrations/block-integrations/basic.md similarity index 100% rename from docs/integrations/basic.md rename to docs/integrations/block-integrations/basic.md diff --git a/docs/integrations/branching.md b/docs/integrations/block-integrations/branching.md similarity index 100% rename from docs/integrations/branching.md rename to docs/integrations/block-integrations/branching.md diff --git a/docs/integrations/compass/triggers.md b/docs/integrations/block-integrations/compass/triggers.md similarity index 100% rename from docs/integrations/compass/triggers.md rename to docs/integrations/block-integrations/compass/triggers.md diff --git a/docs/integrations/csv.md b/docs/integrations/block-integrations/csv.md similarity index 100% rename from docs/integrations/csv.md rename to docs/integrations/block-integrations/csv.md diff --git a/docs/integrations/data.md b/docs/integrations/block-integrations/data.md similarity index 100% rename from docs/integrations/data.md rename to docs/integrations/block-integrations/data.md diff --git a/docs/integrations/dataforseo/keyword_suggestions.md b/docs/integrations/block-integrations/dataforseo/keyword_suggestions.md similarity index 100% rename from docs/integrations/dataforseo/keyword_suggestions.md rename to docs/integrations/block-integrations/dataforseo/keyword_suggestions.md diff --git a/docs/integrations/dataforseo/related_keywords.md b/docs/integrations/block-integrations/dataforseo/related_keywords.md similarity index 100% rename from docs/integrations/dataforseo/related_keywords.md rename to docs/integrations/block-integrations/dataforseo/related_keywords.md diff --git a/docs/integrations/decoder_block.md b/docs/integrations/block-integrations/decoder_block.md similarity index 100% rename from docs/integrations/decoder_block.md rename to docs/integrations/block-integrations/decoder_block.md diff --git a/docs/integrations/discord.md b/docs/integrations/block-integrations/discord.md similarity index 100% rename from docs/integrations/discord.md rename to docs/integrations/block-integrations/discord.md diff --git a/docs/integrations/discord/bot_blocks.md b/docs/integrations/block-integrations/discord/bot_blocks.md similarity index 100% rename from docs/integrations/discord/bot_blocks.md rename to docs/integrations/block-integrations/discord/bot_blocks.md diff --git a/docs/integrations/discord/oauth_blocks.md b/docs/integrations/block-integrations/discord/oauth_blocks.md similarity index 100% rename from docs/integrations/discord/oauth_blocks.md rename to docs/integrations/block-integrations/discord/oauth_blocks.md diff --git a/docs/integrations/email_block.md b/docs/integrations/block-integrations/email_block.md similarity index 100% rename from docs/integrations/email_block.md rename to docs/integrations/block-integrations/email_block.md diff --git a/docs/integrations/enrichlayer/linkedin.md b/docs/integrations/block-integrations/enrichlayer/linkedin.md similarity index 100% rename from docs/integrations/enrichlayer/linkedin.md rename to docs/integrations/block-integrations/enrichlayer/linkedin.md diff --git a/docs/integrations/exa/answers.md b/docs/integrations/block-integrations/exa/answers.md similarity index 100% rename from docs/integrations/exa/answers.md rename to docs/integrations/block-integrations/exa/answers.md diff --git a/docs/integrations/exa/code_context.md b/docs/integrations/block-integrations/exa/code_context.md similarity index 100% rename from docs/integrations/exa/code_context.md rename to docs/integrations/block-integrations/exa/code_context.md diff --git a/docs/integrations/exa/contents.md b/docs/integrations/block-integrations/exa/contents.md similarity index 100% rename from docs/integrations/exa/contents.md rename to docs/integrations/block-integrations/exa/contents.md diff --git a/docs/integrations/exa/research.md b/docs/integrations/block-integrations/exa/research.md similarity index 100% rename from docs/integrations/exa/research.md rename to docs/integrations/block-integrations/exa/research.md diff --git a/docs/integrations/exa/search.md b/docs/integrations/block-integrations/exa/search.md similarity index 100% rename from docs/integrations/exa/search.md rename to docs/integrations/block-integrations/exa/search.md diff --git a/docs/integrations/exa/similar.md b/docs/integrations/block-integrations/exa/similar.md similarity index 100% rename from docs/integrations/exa/similar.md rename to docs/integrations/block-integrations/exa/similar.md diff --git a/docs/integrations/exa/webhook_blocks.md b/docs/integrations/block-integrations/exa/webhook_blocks.md similarity index 100% rename from docs/integrations/exa/webhook_blocks.md rename to docs/integrations/block-integrations/exa/webhook_blocks.md diff --git a/docs/integrations/exa/websets.md b/docs/integrations/block-integrations/exa/websets.md similarity index 100% rename from docs/integrations/exa/websets.md rename to docs/integrations/block-integrations/exa/websets.md diff --git a/docs/integrations/exa/websets_enrichment.md b/docs/integrations/block-integrations/exa/websets_enrichment.md similarity index 100% rename from docs/integrations/exa/websets_enrichment.md rename to docs/integrations/block-integrations/exa/websets_enrichment.md diff --git a/docs/integrations/exa/websets_import_export.md b/docs/integrations/block-integrations/exa/websets_import_export.md similarity index 100% rename from docs/integrations/exa/websets_import_export.md rename to docs/integrations/block-integrations/exa/websets_import_export.md diff --git a/docs/integrations/exa/websets_items.md b/docs/integrations/block-integrations/exa/websets_items.md similarity index 100% rename from docs/integrations/exa/websets_items.md rename to docs/integrations/block-integrations/exa/websets_items.md diff --git a/docs/integrations/exa/websets_monitor.md b/docs/integrations/block-integrations/exa/websets_monitor.md similarity index 100% rename from docs/integrations/exa/websets_monitor.md rename to docs/integrations/block-integrations/exa/websets_monitor.md diff --git a/docs/integrations/exa/websets_polling.md b/docs/integrations/block-integrations/exa/websets_polling.md similarity index 100% rename from docs/integrations/exa/websets_polling.md rename to docs/integrations/block-integrations/exa/websets_polling.md diff --git a/docs/integrations/exa/websets_search.md b/docs/integrations/block-integrations/exa/websets_search.md similarity index 100% rename from docs/integrations/exa/websets_search.md rename to docs/integrations/block-integrations/exa/websets_search.md diff --git a/docs/integrations/fal/ai_video_generator.md b/docs/integrations/block-integrations/fal/ai_video_generator.md similarity index 100% rename from docs/integrations/fal/ai_video_generator.md rename to docs/integrations/block-integrations/fal/ai_video_generator.md diff --git a/docs/integrations/firecrawl/crawl.md b/docs/integrations/block-integrations/firecrawl/crawl.md similarity index 100% rename from docs/integrations/firecrawl/crawl.md rename to docs/integrations/block-integrations/firecrawl/crawl.md diff --git a/docs/integrations/firecrawl/extract.md b/docs/integrations/block-integrations/firecrawl/extract.md similarity index 100% rename from docs/integrations/firecrawl/extract.md rename to docs/integrations/block-integrations/firecrawl/extract.md diff --git a/docs/integrations/firecrawl/map.md b/docs/integrations/block-integrations/firecrawl/map.md similarity index 100% rename from docs/integrations/firecrawl/map.md rename to docs/integrations/block-integrations/firecrawl/map.md diff --git a/docs/integrations/firecrawl/scrape.md b/docs/integrations/block-integrations/firecrawl/scrape.md similarity index 100% rename from docs/integrations/firecrawl/scrape.md rename to docs/integrations/block-integrations/firecrawl/scrape.md diff --git a/docs/integrations/firecrawl/search.md b/docs/integrations/block-integrations/firecrawl/search.md similarity index 100% rename from docs/integrations/firecrawl/search.md rename to docs/integrations/block-integrations/firecrawl/search.md diff --git a/docs/integrations/flux_kontext.md b/docs/integrations/block-integrations/flux_kontext.md similarity index 100% rename from docs/integrations/flux_kontext.md rename to docs/integrations/block-integrations/flux_kontext.md diff --git a/docs/integrations/generic_webhook/triggers.md b/docs/integrations/block-integrations/generic_webhook/triggers.md similarity index 100% rename from docs/integrations/generic_webhook/triggers.md rename to docs/integrations/block-integrations/generic_webhook/triggers.md diff --git a/docs/integrations/github/checks.md b/docs/integrations/block-integrations/github/checks.md similarity index 100% rename from docs/integrations/github/checks.md rename to docs/integrations/block-integrations/github/checks.md diff --git a/docs/integrations/github/ci.md b/docs/integrations/block-integrations/github/ci.md similarity index 100% rename from docs/integrations/github/ci.md rename to docs/integrations/block-integrations/github/ci.md diff --git a/docs/integrations/github/issues.md b/docs/integrations/block-integrations/github/issues.md similarity index 100% rename from docs/integrations/github/issues.md rename to docs/integrations/block-integrations/github/issues.md diff --git a/docs/integrations/github/pull_requests.md b/docs/integrations/block-integrations/github/pull_requests.md similarity index 100% rename from docs/integrations/github/pull_requests.md rename to docs/integrations/block-integrations/github/pull_requests.md diff --git a/docs/integrations/github/repo.md b/docs/integrations/block-integrations/github/repo.md similarity index 100% rename from docs/integrations/github/repo.md rename to docs/integrations/block-integrations/github/repo.md diff --git a/docs/integrations/github/reviews.md b/docs/integrations/block-integrations/github/reviews.md similarity index 100% rename from docs/integrations/github/reviews.md rename to docs/integrations/block-integrations/github/reviews.md diff --git a/docs/integrations/github/statuses.md b/docs/integrations/block-integrations/github/statuses.md similarity index 100% rename from docs/integrations/github/statuses.md rename to docs/integrations/block-integrations/github/statuses.md diff --git a/docs/integrations/github/triggers.md b/docs/integrations/block-integrations/github/triggers.md similarity index 100% rename from docs/integrations/github/triggers.md rename to docs/integrations/block-integrations/github/triggers.md diff --git a/docs/integrations/google/calendar.md b/docs/integrations/block-integrations/google/calendar.md similarity index 100% rename from docs/integrations/google/calendar.md rename to docs/integrations/block-integrations/google/calendar.md diff --git a/docs/integrations/google/docs.md b/docs/integrations/block-integrations/google/docs.md similarity index 100% rename from docs/integrations/google/docs.md rename to docs/integrations/block-integrations/google/docs.md diff --git a/docs/integrations/google/gmail.md b/docs/integrations/block-integrations/google/gmail.md similarity index 100% rename from docs/integrations/google/gmail.md rename to docs/integrations/block-integrations/google/gmail.md diff --git a/docs/integrations/google/sheet.md b/docs/integrations/block-integrations/google/sheet.md similarity index 100% rename from docs/integrations/google/sheet.md rename to docs/integrations/block-integrations/google/sheet.md diff --git a/docs/integrations/google/sheets.md b/docs/integrations/block-integrations/google/sheets.md similarity index 100% rename from docs/integrations/google/sheets.md rename to docs/integrations/block-integrations/google/sheets.md diff --git a/docs/integrations/google_maps.md b/docs/integrations/block-integrations/google_maps.md similarity index 100% rename from docs/integrations/google_maps.md rename to docs/integrations/block-integrations/google_maps.md diff --git a/docs/integrations/http.md b/docs/integrations/block-integrations/http.md similarity index 100% rename from docs/integrations/http.md rename to docs/integrations/block-integrations/http.md diff --git a/docs/integrations/hubspot/company.md b/docs/integrations/block-integrations/hubspot/company.md similarity index 100% rename from docs/integrations/hubspot/company.md rename to docs/integrations/block-integrations/hubspot/company.md diff --git a/docs/integrations/hubspot/contact.md b/docs/integrations/block-integrations/hubspot/contact.md similarity index 100% rename from docs/integrations/hubspot/contact.md rename to docs/integrations/block-integrations/hubspot/contact.md diff --git a/docs/integrations/hubspot/engagement.md b/docs/integrations/block-integrations/hubspot/engagement.md similarity index 100% rename from docs/integrations/hubspot/engagement.md rename to docs/integrations/block-integrations/hubspot/engagement.md diff --git a/docs/integrations/ideogram.md b/docs/integrations/block-integrations/ideogram.md similarity index 100% rename from docs/integrations/ideogram.md rename to docs/integrations/block-integrations/ideogram.md diff --git a/docs/integrations/iteration.md b/docs/integrations/block-integrations/iteration.md similarity index 100% rename from docs/integrations/iteration.md rename to docs/integrations/block-integrations/iteration.md diff --git a/docs/integrations/jina/chunking.md b/docs/integrations/block-integrations/jina/chunking.md similarity index 100% rename from docs/integrations/jina/chunking.md rename to docs/integrations/block-integrations/jina/chunking.md diff --git a/docs/integrations/jina/embeddings.md b/docs/integrations/block-integrations/jina/embeddings.md similarity index 100% rename from docs/integrations/jina/embeddings.md rename to docs/integrations/block-integrations/jina/embeddings.md diff --git a/docs/integrations/jina/fact_checker.md b/docs/integrations/block-integrations/jina/fact_checker.md similarity index 100% rename from docs/integrations/jina/fact_checker.md rename to docs/integrations/block-integrations/jina/fact_checker.md diff --git a/docs/integrations/jina/search.md b/docs/integrations/block-integrations/jina/search.md similarity index 100% rename from docs/integrations/jina/search.md rename to docs/integrations/block-integrations/jina/search.md diff --git a/docs/integrations/linear/comment.md b/docs/integrations/block-integrations/linear/comment.md similarity index 100% rename from docs/integrations/linear/comment.md rename to docs/integrations/block-integrations/linear/comment.md diff --git a/docs/integrations/linear/issues.md b/docs/integrations/block-integrations/linear/issues.md similarity index 100% rename from docs/integrations/linear/issues.md rename to docs/integrations/block-integrations/linear/issues.md diff --git a/docs/integrations/linear/projects.md b/docs/integrations/block-integrations/linear/projects.md similarity index 100% rename from docs/integrations/linear/projects.md rename to docs/integrations/block-integrations/linear/projects.md diff --git a/docs/integrations/llm.md b/docs/integrations/block-integrations/llm.md similarity index 100% rename from docs/integrations/llm.md rename to docs/integrations/block-integrations/llm.md diff --git a/docs/integrations/logic.md b/docs/integrations/block-integrations/logic.md similarity index 100% rename from docs/integrations/logic.md rename to docs/integrations/block-integrations/logic.md diff --git a/docs/integrations/maths.md b/docs/integrations/block-integrations/maths.md similarity index 100% rename from docs/integrations/maths.md rename to docs/integrations/block-integrations/maths.md diff --git a/docs/integrations/medium.md b/docs/integrations/block-integrations/medium.md similarity index 100% rename from docs/integrations/medium.md rename to docs/integrations/block-integrations/medium.md diff --git a/docs/integrations/misc.md b/docs/integrations/block-integrations/misc.md similarity index 100% rename from docs/integrations/misc.md rename to docs/integrations/block-integrations/misc.md diff --git a/docs/integrations/multimedia.md b/docs/integrations/block-integrations/multimedia.md similarity index 100% rename from docs/integrations/multimedia.md rename to docs/integrations/block-integrations/multimedia.md diff --git a/docs/integrations/notion/create_page.md b/docs/integrations/block-integrations/notion/create_page.md similarity index 100% rename from docs/integrations/notion/create_page.md rename to docs/integrations/block-integrations/notion/create_page.md diff --git a/docs/integrations/notion/read_database.md b/docs/integrations/block-integrations/notion/read_database.md similarity index 100% rename from docs/integrations/notion/read_database.md rename to docs/integrations/block-integrations/notion/read_database.md diff --git a/docs/integrations/notion/read_page.md b/docs/integrations/block-integrations/notion/read_page.md similarity index 100% rename from docs/integrations/notion/read_page.md rename to docs/integrations/block-integrations/notion/read_page.md diff --git a/docs/integrations/notion/read_page_markdown.md b/docs/integrations/block-integrations/notion/read_page_markdown.md similarity index 100% rename from docs/integrations/notion/read_page_markdown.md rename to docs/integrations/block-integrations/notion/read_page_markdown.md diff --git a/docs/integrations/notion/search.md b/docs/integrations/block-integrations/notion/search.md similarity index 100% rename from docs/integrations/notion/search.md rename to docs/integrations/block-integrations/notion/search.md diff --git a/docs/integrations/nvidia/deepfake.md b/docs/integrations/block-integrations/nvidia/deepfake.md similarity index 100% rename from docs/integrations/nvidia/deepfake.md rename to docs/integrations/block-integrations/nvidia/deepfake.md diff --git a/docs/integrations/reddit.md b/docs/integrations/block-integrations/reddit.md similarity index 100% rename from docs/integrations/reddit.md rename to docs/integrations/block-integrations/reddit.md diff --git a/docs/integrations/replicate/flux_advanced.md b/docs/integrations/block-integrations/replicate/flux_advanced.md similarity index 100% rename from docs/integrations/replicate/flux_advanced.md rename to docs/integrations/block-integrations/replicate/flux_advanced.md diff --git a/docs/integrations/replicate/replicate_block.md b/docs/integrations/block-integrations/replicate/replicate_block.md similarity index 100% rename from docs/integrations/replicate/replicate_block.md rename to docs/integrations/block-integrations/replicate/replicate_block.md diff --git a/docs/integrations/replicate_flux_advanced.md b/docs/integrations/block-integrations/replicate_flux_advanced.md similarity index 100% rename from docs/integrations/replicate_flux_advanced.md rename to docs/integrations/block-integrations/replicate_flux_advanced.md diff --git a/docs/integrations/rss.md b/docs/integrations/block-integrations/rss.md similarity index 100% rename from docs/integrations/rss.md rename to docs/integrations/block-integrations/rss.md diff --git a/docs/integrations/sampling.md b/docs/integrations/block-integrations/sampling.md similarity index 100% rename from docs/integrations/sampling.md rename to docs/integrations/block-integrations/sampling.md diff --git a/docs/integrations/search.md b/docs/integrations/block-integrations/search.md similarity index 100% rename from docs/integrations/search.md rename to docs/integrations/block-integrations/search.md diff --git a/docs/integrations/slant3d/filament.md b/docs/integrations/block-integrations/slant3d/filament.md similarity index 100% rename from docs/integrations/slant3d/filament.md rename to docs/integrations/block-integrations/slant3d/filament.md diff --git a/docs/integrations/slant3d/order.md b/docs/integrations/block-integrations/slant3d/order.md similarity index 100% rename from docs/integrations/slant3d/order.md rename to docs/integrations/block-integrations/slant3d/order.md diff --git a/docs/integrations/slant3d/slicing.md b/docs/integrations/block-integrations/slant3d/slicing.md similarity index 100% rename from docs/integrations/slant3d/slicing.md rename to docs/integrations/block-integrations/slant3d/slicing.md diff --git a/docs/integrations/slant3d/webhook.md b/docs/integrations/block-integrations/slant3d/webhook.md similarity index 100% rename from docs/integrations/slant3d/webhook.md rename to docs/integrations/block-integrations/slant3d/webhook.md diff --git a/docs/integrations/smartlead/campaign.md b/docs/integrations/block-integrations/smartlead/campaign.md similarity index 100% rename from docs/integrations/smartlead/campaign.md rename to docs/integrations/block-integrations/smartlead/campaign.md diff --git a/docs/integrations/stagehand/blocks.md b/docs/integrations/block-integrations/stagehand/blocks.md similarity index 100% rename from docs/integrations/stagehand/blocks.md rename to docs/integrations/block-integrations/stagehand/blocks.md diff --git a/docs/integrations/system/library_operations.md b/docs/integrations/block-integrations/system/library_operations.md similarity index 100% rename from docs/integrations/system/library_operations.md rename to docs/integrations/block-integrations/system/library_operations.md diff --git a/docs/integrations/system/store_operations.md b/docs/integrations/block-integrations/system/store_operations.md similarity index 100% rename from docs/integrations/system/store_operations.md rename to docs/integrations/block-integrations/system/store_operations.md diff --git a/docs/integrations/talking_head.md b/docs/integrations/block-integrations/talking_head.md similarity index 100% rename from docs/integrations/talking_head.md rename to docs/integrations/block-integrations/talking_head.md diff --git a/docs/integrations/text.md b/docs/integrations/block-integrations/text.md similarity index 100% rename from docs/integrations/text.md rename to docs/integrations/block-integrations/text.md diff --git a/docs/integrations/text_to_speech_block.md b/docs/integrations/block-integrations/text_to_speech_block.md similarity index 100% rename from docs/integrations/text_to_speech_block.md rename to docs/integrations/block-integrations/text_to_speech_block.md diff --git a/docs/integrations/time_blocks.md b/docs/integrations/block-integrations/time_blocks.md similarity index 100% rename from docs/integrations/time_blocks.md rename to docs/integrations/block-integrations/time_blocks.md diff --git a/docs/integrations/todoist.md b/docs/integrations/block-integrations/todoist.md similarity index 100% rename from docs/integrations/todoist.md rename to docs/integrations/block-integrations/todoist.md diff --git a/docs/integrations/todoist/comments.md b/docs/integrations/block-integrations/todoist/comments.md similarity index 100% rename from docs/integrations/todoist/comments.md rename to docs/integrations/block-integrations/todoist/comments.md diff --git a/docs/integrations/todoist/labels.md b/docs/integrations/block-integrations/todoist/labels.md similarity index 100% rename from docs/integrations/todoist/labels.md rename to docs/integrations/block-integrations/todoist/labels.md diff --git a/docs/integrations/todoist/projects.md b/docs/integrations/block-integrations/todoist/projects.md similarity index 100% rename from docs/integrations/todoist/projects.md rename to docs/integrations/block-integrations/todoist/projects.md diff --git a/docs/integrations/todoist/sections.md b/docs/integrations/block-integrations/todoist/sections.md similarity index 100% rename from docs/integrations/todoist/sections.md rename to docs/integrations/block-integrations/todoist/sections.md diff --git a/docs/integrations/todoist/tasks.md b/docs/integrations/block-integrations/todoist/tasks.md similarity index 100% rename from docs/integrations/todoist/tasks.md rename to docs/integrations/block-integrations/todoist/tasks.md diff --git a/docs/integrations/twitter/blocks.md b/docs/integrations/block-integrations/twitter/blocks.md similarity index 100% rename from docs/integrations/twitter/blocks.md rename to docs/integrations/block-integrations/twitter/blocks.md diff --git a/docs/integrations/twitter/bookmark.md b/docs/integrations/block-integrations/twitter/bookmark.md similarity index 100% rename from docs/integrations/twitter/bookmark.md rename to docs/integrations/block-integrations/twitter/bookmark.md diff --git a/docs/integrations/twitter/follows.md b/docs/integrations/block-integrations/twitter/follows.md similarity index 100% rename from docs/integrations/twitter/follows.md rename to docs/integrations/block-integrations/twitter/follows.md diff --git a/docs/integrations/twitter/hide.md b/docs/integrations/block-integrations/twitter/hide.md similarity index 100% rename from docs/integrations/twitter/hide.md rename to docs/integrations/block-integrations/twitter/hide.md diff --git a/docs/integrations/twitter/like.md b/docs/integrations/block-integrations/twitter/like.md similarity index 100% rename from docs/integrations/twitter/like.md rename to docs/integrations/block-integrations/twitter/like.md diff --git a/docs/integrations/twitter/list_follows.md b/docs/integrations/block-integrations/twitter/list_follows.md similarity index 100% rename from docs/integrations/twitter/list_follows.md rename to docs/integrations/block-integrations/twitter/list_follows.md diff --git a/docs/integrations/twitter/list_lookup.md b/docs/integrations/block-integrations/twitter/list_lookup.md similarity index 100% rename from docs/integrations/twitter/list_lookup.md rename to docs/integrations/block-integrations/twitter/list_lookup.md diff --git a/docs/integrations/twitter/list_members.md b/docs/integrations/block-integrations/twitter/list_members.md similarity index 100% rename from docs/integrations/twitter/list_members.md rename to docs/integrations/block-integrations/twitter/list_members.md diff --git a/docs/integrations/twitter/list_tweets_lookup.md b/docs/integrations/block-integrations/twitter/list_tweets_lookup.md similarity index 100% rename from docs/integrations/twitter/list_tweets_lookup.md rename to docs/integrations/block-integrations/twitter/list_tweets_lookup.md diff --git a/docs/integrations/twitter/manage.md b/docs/integrations/block-integrations/twitter/manage.md similarity index 100% rename from docs/integrations/twitter/manage.md rename to docs/integrations/block-integrations/twitter/manage.md diff --git a/docs/integrations/twitter/manage_lists.md b/docs/integrations/block-integrations/twitter/manage_lists.md similarity index 100% rename from docs/integrations/twitter/manage_lists.md rename to docs/integrations/block-integrations/twitter/manage_lists.md diff --git a/docs/integrations/twitter/mutes.md b/docs/integrations/block-integrations/twitter/mutes.md similarity index 100% rename from docs/integrations/twitter/mutes.md rename to docs/integrations/block-integrations/twitter/mutes.md diff --git a/docs/integrations/twitter/pinned_lists.md b/docs/integrations/block-integrations/twitter/pinned_lists.md similarity index 100% rename from docs/integrations/twitter/pinned_lists.md rename to docs/integrations/block-integrations/twitter/pinned_lists.md diff --git a/docs/integrations/twitter/quote.md b/docs/integrations/block-integrations/twitter/quote.md similarity index 100% rename from docs/integrations/twitter/quote.md rename to docs/integrations/block-integrations/twitter/quote.md diff --git a/docs/integrations/twitter/retweet.md b/docs/integrations/block-integrations/twitter/retweet.md similarity index 100% rename from docs/integrations/twitter/retweet.md rename to docs/integrations/block-integrations/twitter/retweet.md diff --git a/docs/integrations/twitter/search_spaces.md b/docs/integrations/block-integrations/twitter/search_spaces.md similarity index 100% rename from docs/integrations/twitter/search_spaces.md rename to docs/integrations/block-integrations/twitter/search_spaces.md diff --git a/docs/integrations/twitter/spaces_lookup.md b/docs/integrations/block-integrations/twitter/spaces_lookup.md similarity index 100% rename from docs/integrations/twitter/spaces_lookup.md rename to docs/integrations/block-integrations/twitter/spaces_lookup.md diff --git a/docs/integrations/twitter/timeline.md b/docs/integrations/block-integrations/twitter/timeline.md similarity index 100% rename from docs/integrations/twitter/timeline.md rename to docs/integrations/block-integrations/twitter/timeline.md diff --git a/docs/integrations/twitter/tweet_lookup.md b/docs/integrations/block-integrations/twitter/tweet_lookup.md similarity index 100% rename from docs/integrations/twitter/tweet_lookup.md rename to docs/integrations/block-integrations/twitter/tweet_lookup.md diff --git a/docs/integrations/twitter/twitter.md b/docs/integrations/block-integrations/twitter/twitter.md similarity index 100% rename from docs/integrations/twitter/twitter.md rename to docs/integrations/block-integrations/twitter/twitter.md diff --git a/docs/integrations/twitter/user_lookup.md b/docs/integrations/block-integrations/twitter/user_lookup.md similarity index 100% rename from docs/integrations/twitter/user_lookup.md rename to docs/integrations/block-integrations/twitter/user_lookup.md diff --git a/docs/integrations/wolfram/llm_api.md b/docs/integrations/block-integrations/wolfram/llm_api.md similarity index 100% rename from docs/integrations/wolfram/llm_api.md rename to docs/integrations/block-integrations/wolfram/llm_api.md diff --git a/docs/integrations/youtube.md b/docs/integrations/block-integrations/youtube.md similarity index 100% rename from docs/integrations/youtube.md rename to docs/integrations/block-integrations/youtube.md diff --git a/docs/integrations/zerobounce/validate_emails.md b/docs/integrations/block-integrations/zerobounce/validate_emails.md similarity index 100% rename from docs/integrations/zerobounce/validate_emails.md rename to docs/integrations/block-integrations/zerobounce/validate_emails.md diff --git a/docs/integrations/guides/llm-providers.md b/docs/integrations/guides/llm-providers.md new file mode 100644 index 0000000000..73d789b479 --- /dev/null +++ b/docs/integrations/guides/llm-providers.md @@ -0,0 +1,16 @@ +# LLM Providers + +There are several providers that AutoGPT users can use for running inference with LLM models. + +## Llama API + +Llama API is a Meta-hosted API service that helps you integrate Llama models quickly and efficiently. Using OpenAI compatibility endpoints, you can easily access the power of Llama models without the need for complex setup or configuration! + +Join the [waitlist](https://llama.developer.meta.com/?utm_source=partner-autogpt&utm_medium=readme) to get access! + +Try the Llama API provider by selecting any of the following LLM Model names from the AI blocks: + +* Llama-4-Scout-17B-16E-Instruct-FP8 +* Llama-4-Maverick-17B-128E-Instruct-FP8 +* Llama-3.3-8B-Instruct +* Llama-3-70B-Instruct diff --git a/docs/integrations/guides/voice-providers.md b/docs/integrations/guides/voice-providers.md new file mode 100644 index 0000000000..450bed0676 --- /dev/null +++ b/docs/integrations/guides/voice-providers.md @@ -0,0 +1,22 @@ +# Voice Providers for D-ID + +This guide covers the voice providers you can use with the D-ID Create Talking Avatar Video block. + +## ElevenLabs + +1. Select any voice from the voice list: [https://api.elevenlabs.io/v1/voices](https://api.elevenlabs.io/v1/voices) +2. Copy the `voice_id` +3. Use it as a string in the `voice_id` field in the CreateTalkingAvatarClip Block + +## Microsoft Azure Voices + +1. Select any voice from the voice gallery: [https://speech.microsoft.com/portal/voicegallery](https://speech.microsoft.com/portal/voicegallery) +2. Click on the "Sample code" tab on the right +3. Copy the voice name, for example: `config.SpeechSynthesisVoiceName = "en-GB-AbbiNeural"` +4. Use this string `en-GB-AbbiNeural` in the `voice_id` field in the CreateTalkingAvatarClip Block + +## Amazon Polly Voices + +1. Select any voice from the voice list: [https://docs.aws.amazon.com/polly/latest/dg/available-voices.html](https://docs.aws.amazon.com/polly/latest/dg/available-voices.html) +2. Copy the voice name / ID +3. Use it as string in the `voice_id` field in the CreateTalkingAvatarClip Block From 82d7134fc6378357be868abf8f59fd915c7d2123 Mon Sep 17 00:00:00 2001 From: Bently Date: Fri, 23 Jan 2026 11:05:32 +0100 Subject: [PATCH 03/36] feat(blocks): Add ClaudeCodeBlock for executing tasks via Claude Code in E2B sandbox (#11761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new ClaudeCodeBlock that enables execution of coding tasks using Anthropic's Claude Code in an E2B sandbox. This block unlocks powerful agentic coding capabilities - Claude Code can autonomously create files, install packages, run commands, and build complete applications within a secure sandboxed environment. Changes šŸ—ļø - New file backend/blocks/claude_code.py: - ClaudeCodeBlock - Execute tasks using Claude Code in an E2B sandbox - Dual credential support: E2B API key (sandbox) + Anthropic API key (Claude Code) - Session continuation support via session_id, sandbox_id, and conversation_history - Automatic file extraction with path, relative_path, name, and content fields - Configurable timeout, setup commands, and working directory - dispose_sandbox option to keep sandbox alive for multi-turn conversations Checklist šŸ“‹ For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Create and execute ClaudeCodeBlock with a simple prompt ("Create a hello world HTML file") - [x] Verify files output includes correct path, relative_path, name, and content - [x] Test session continuation by passing session_id and sandbox_id back - [x] Build "Any API → Instant App" demo agent combining Firecrawl + ClaudeCodeBlock + GitHub blocks - [x] Verify generated files are pushed to GitHub with correct folder structure using relative_path Here are two example agents i made that can be used to test this agent, they require github, anthropic and e2b access via api keys that are set via the user/on the platform is testing on dev The first agent is my Any API → Instant App "Transform any API documentation into a fully functional web application. Just provide a docs URL and get a complete, ready-to-deploy app pushed to a new GitHub repository." [Any API → Instant App_v36.json](https://github.com/user-attachments/files/24600326/Any.API.Instant.App_v36.json) The second agent is my Idea to project "Simply enter your coding project's idea and this agent will make all of the base initial code needed for you to start working on that project and place it on github for you!" [Idea to project_v11.json](https://github.com/user-attachments/files/24600346/Idea.to.project_v11.json) If you have any questions or issues let me know. References https://e2b.dev/blog/python-guide-run-claude-code-in-an-e2b-sandbox https://github.com/e2b-dev/e2b-cookbook/tree/main/examples/anthropic-claude-code-in-sandbox-python https://code.claude.com/docs/en/cli-reference I tried to use E2b's "anthropic-claude-code" template but it kept complaining it was out of date, so I make it manually spin up a E2b instance and make it install the latest claude code and it uses that --- .../backend/backend/blocks/claude_code.py | 659 ++++++++++++++++++ docs/integrations/README.md | 1 + .../block-integrations/claude_code.md | 67 ++ docs/integrations/block-integrations/llm.md | 56 ++ 4 files changed, 783 insertions(+) create mode 100644 autogpt_platform/backend/backend/blocks/claude_code.py create mode 100644 docs/integrations/block-integrations/claude_code.md diff --git a/autogpt_platform/backend/backend/blocks/claude_code.py b/autogpt_platform/backend/backend/blocks/claude_code.py new file mode 100644 index 0000000000..4ef44603b2 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/claude_code.py @@ -0,0 +1,659 @@ +import json +import shlex +import uuid +from typing import Literal, Optional + +from e2b import AsyncSandbox as BaseAsyncSandbox +from pydantic import BaseModel, SecretStr + +from backend.data.block import ( + Block, + BlockCategory, + BlockOutput, + BlockSchemaInput, + BlockSchemaOutput, +) +from backend.data.model import ( + APIKeyCredentials, + CredentialsField, + CredentialsMetaInput, + SchemaField, +) +from backend.integrations.providers import ProviderName + + +class ClaudeCodeExecutionError(Exception): + """Exception raised when Claude Code execution fails. + + Carries the sandbox_id so it can be returned to the user for cleanup + when dispose_sandbox=False. + """ + + def __init__(self, message: str, sandbox_id: str = ""): + super().__init__(message) + self.sandbox_id = sandbox_id + + +# Test credentials for E2B +TEST_E2B_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="e2b", + api_key=SecretStr("mock-e2b-api-key"), + title="Mock E2B API key", + expires_at=None, +) +TEST_E2B_CREDENTIALS_INPUT = { + "provider": TEST_E2B_CREDENTIALS.provider, + "id": TEST_E2B_CREDENTIALS.id, + "type": TEST_E2B_CREDENTIALS.type, + "title": TEST_E2B_CREDENTIALS.title, +} + +# Test credentials for Anthropic +TEST_ANTHROPIC_CREDENTIALS = APIKeyCredentials( + id="2e568a2b-b2ea-475a-8564-9a676bf31c56", + provider="anthropic", + api_key=SecretStr("mock-anthropic-api-key"), + title="Mock Anthropic API key", + expires_at=None, +) +TEST_ANTHROPIC_CREDENTIALS_INPUT = { + "provider": TEST_ANTHROPIC_CREDENTIALS.provider, + "id": TEST_ANTHROPIC_CREDENTIALS.id, + "type": TEST_ANTHROPIC_CREDENTIALS.type, + "title": TEST_ANTHROPIC_CREDENTIALS.title, +} + + +class ClaudeCodeBlock(Block): + """ + Execute tasks using Claude Code (Anthropic's AI coding assistant) in an E2B sandbox. + + Claude Code can create files, install tools, run commands, and perform complex + coding tasks autonomously within a secure sandbox environment. + """ + + # Use base template - we'll install Claude Code ourselves for latest version + DEFAULT_TEMPLATE = "base" + + class Input(BlockSchemaInput): + e2b_credentials: CredentialsMetaInput[ + Literal[ProviderName.E2B], Literal["api_key"] + ] = CredentialsField( + description=( + "API key for the E2B platform to create the sandbox. " + "Get one on the [e2b website](https://e2b.dev/docs)" + ), + ) + + anthropic_credentials: CredentialsMetaInput[ + Literal[ProviderName.ANTHROPIC], Literal["api_key"] + ] = CredentialsField( + description=( + "API key for Anthropic to power Claude Code. " + "Get one at [Anthropic's website](https://console.anthropic.com)" + ), + ) + + prompt: str = SchemaField( + description=( + "The task or instruction for Claude Code to execute. " + "Claude Code can create files, install packages, run commands, " + "and perform complex coding tasks." + ), + placeholder="Create a hello world index.html file", + default="", + advanced=False, + ) + + timeout: int = SchemaField( + description=( + "Sandbox timeout in seconds. Claude Code tasks can take " + "a while, so set this appropriately for your task complexity. " + "Note: This only applies when creating a new sandbox. " + "When reconnecting to an existing sandbox via sandbox_id, " + "the original timeout is retained." + ), + default=300, # 5 minutes default + advanced=True, + ) + + setup_commands: list[str] = SchemaField( + description=( + "Optional shell commands to run before executing Claude Code. " + "Useful for installing dependencies or setting up the environment." + ), + default_factory=list, + advanced=True, + ) + + working_directory: str = SchemaField( + description="Working directory for Claude Code to operate in.", + default="/home/user", + advanced=True, + ) + + # Session/continuation support + session_id: str = SchemaField( + description=( + "Session ID to resume a previous conversation. " + "Leave empty for a new conversation. " + "Use the session_id from a previous run to continue that conversation." + ), + default="", + advanced=True, + ) + + sandbox_id: str = SchemaField( + description=( + "Sandbox ID to reconnect to an existing sandbox. " + "Required when resuming a session (along with session_id). " + "Use the sandbox_id from a previous run where dispose_sandbox was False." + ), + default="", + advanced=True, + ) + + conversation_history: str = SchemaField( + description=( + "Previous conversation history to continue from. " + "Use this to restore context on a fresh sandbox if the previous one timed out. " + "Pass the conversation_history output from a previous run." + ), + default="", + advanced=True, + ) + + dispose_sandbox: bool = SchemaField( + description=( + "Whether to dispose of the sandbox immediately after execution. " + "Set to False if you want to continue the conversation later " + "(you'll need both sandbox_id and session_id from the output)." + ), + default=True, + advanced=True, + ) + + class FileOutput(BaseModel): + """A file extracted from the sandbox.""" + + path: str + relative_path: str # Path relative to working directory (for GitHub, etc.) + name: str + content: str + + class Output(BlockSchemaOutput): + response: str = SchemaField( + description="The output/response from Claude Code execution" + ) + files: list["ClaudeCodeBlock.FileOutput"] = SchemaField( + description=( + "List of text files created/modified by Claude Code during this execution. " + "Each file has 'path', 'relative_path', 'name', and 'content' fields." + ) + ) + conversation_history: str = SchemaField( + description=( + "Full conversation history including this turn. " + "Pass this to conversation_history input to continue on a fresh sandbox " + "if the previous sandbox timed out." + ) + ) + session_id: str = SchemaField( + description=( + "Session ID for this conversation. " + "Pass this back along with sandbox_id to continue the conversation." + ) + ) + sandbox_id: Optional[str] = SchemaField( + description=( + "ID of the sandbox instance. " + "Pass this back along with session_id to continue the conversation. " + "This is None if dispose_sandbox was True (sandbox was disposed)." + ), + default=None, + ) + error: str = SchemaField(description="Error message if execution failed") + + def __init__(self): + super().__init__( + id="4e34f4a5-9b89-4326-ba77-2dd6750b7194", + description=( + "Execute tasks using Claude Code in an E2B sandbox. " + "Claude Code can create files, install tools, run commands, " + "and perform complex coding tasks autonomously." + ), + categories={BlockCategory.DEVELOPER_TOOLS, BlockCategory.AI}, + input_schema=ClaudeCodeBlock.Input, + output_schema=ClaudeCodeBlock.Output, + test_credentials={ + "e2b_credentials": TEST_E2B_CREDENTIALS, + "anthropic_credentials": TEST_ANTHROPIC_CREDENTIALS, + }, + test_input={ + "e2b_credentials": TEST_E2B_CREDENTIALS_INPUT, + "anthropic_credentials": TEST_ANTHROPIC_CREDENTIALS_INPUT, + "prompt": "Create a hello world HTML file", + "timeout": 300, + "setup_commands": [], + "working_directory": "/home/user", + "session_id": "", + "sandbox_id": "", + "conversation_history": "", + "dispose_sandbox": True, + }, + test_output=[ + ("response", "Created index.html with hello world content"), + ( + "files", + [ + { + "path": "/home/user/index.html", + "relative_path": "index.html", + "name": "index.html", + "content": "Hello World", + } + ], + ), + ( + "conversation_history", + "User: Create a hello world HTML file\n" + "Claude: Created index.html with hello world content", + ), + ("session_id", str), + ("sandbox_id", None), # None because dispose_sandbox=True in test_input + ], + test_mock={ + "execute_claude_code": lambda *args, **kwargs: ( + "Created index.html with hello world content", # response + [ + ClaudeCodeBlock.FileOutput( + path="/home/user/index.html", + relative_path="index.html", + name="index.html", + content="Hello World", + ) + ], # files + "User: Create a hello world HTML file\n" + "Claude: Created index.html with hello world content", # conversation_history + "test-session-id", # session_id + "sandbox_id", # sandbox_id + ), + }, + ) + + async def execute_claude_code( + self, + e2b_api_key: str, + anthropic_api_key: str, + prompt: str, + timeout: int, + setup_commands: list[str], + working_directory: str, + session_id: str, + existing_sandbox_id: str, + conversation_history: str, + dispose_sandbox: bool, + ) -> tuple[str, list["ClaudeCodeBlock.FileOutput"], str, str, str]: + """ + Execute Claude Code in an E2B sandbox. + + Returns: + Tuple of (response, files, conversation_history, session_id, sandbox_id) + """ + + # Validate that sandbox_id is provided when resuming a session + if session_id and not existing_sandbox_id: + raise ValueError( + "sandbox_id is required when resuming a session with session_id. " + "The session state is stored in the original sandbox. " + "If the sandbox has timed out, use conversation_history instead " + "to restore context on a fresh sandbox." + ) + + sandbox = None + sandbox_id = "" + + try: + # Either reconnect to existing sandbox or create a new one + if existing_sandbox_id: + # Reconnect to existing sandbox for conversation continuation + sandbox = await BaseAsyncSandbox.connect( + sandbox_id=existing_sandbox_id, + api_key=e2b_api_key, + ) + else: + # Create new sandbox + sandbox = await BaseAsyncSandbox.create( + template=self.DEFAULT_TEMPLATE, + api_key=e2b_api_key, + timeout=timeout, + envs={"ANTHROPIC_API_KEY": anthropic_api_key}, + ) + + # Install Claude Code from npm (ensures we get the latest version) + install_result = await sandbox.commands.run( + "npm install -g @anthropic-ai/claude-code@latest", + timeout=120, # 2 min timeout for install + ) + if install_result.exit_code != 0: + raise Exception( + f"Failed to install Claude Code: {install_result.stderr}" + ) + + # Run any user-provided setup commands + for cmd in setup_commands: + setup_result = await sandbox.commands.run(cmd) + if setup_result.exit_code != 0: + raise Exception( + f"Setup command failed: {cmd}\n" + f"Exit code: {setup_result.exit_code}\n" + f"Stdout: {setup_result.stdout}\n" + f"Stderr: {setup_result.stderr}" + ) + + # Capture sandbox_id immediately after creation/connection + # so it's available for error recovery if dispose_sandbox=False + sandbox_id = sandbox.sandbox_id + + # Generate or use provided session ID + current_session_id = session_id if session_id else str(uuid.uuid4()) + + # Build base Claude flags + base_flags = "-p --dangerously-skip-permissions --output-format json" + + # Add conversation history context if provided (for fresh sandbox continuation) + history_flag = "" + if conversation_history and not session_id: + # Inject previous conversation as context via system prompt + # Use consistent escaping via _escape_prompt helper + escaped_history = self._escape_prompt( + f"Previous conversation context: {conversation_history}" + ) + history_flag = f" --append-system-prompt {escaped_history}" + + # Build Claude command based on whether we're resuming or starting new + # Use shlex.quote for working_directory and session IDs to prevent injection + safe_working_dir = shlex.quote(working_directory) + if session_id: + # Resuming existing session (sandbox still alive) + safe_session_id = shlex.quote(session_id) + claude_command = ( + f"cd {safe_working_dir} && " + f"echo {self._escape_prompt(prompt)} | " + f"claude --resume {safe_session_id} {base_flags}" + ) + else: + # New session with specific ID + safe_current_session_id = shlex.quote(current_session_id) + claude_command = ( + f"cd {safe_working_dir} && " + f"echo {self._escape_prompt(prompt)} | " + f"claude --session-id {safe_current_session_id} {base_flags}{history_flag}" + ) + + # Capture timestamp before running Claude Code to filter files later + # Capture timestamp 1 second in the past to avoid race condition with file creation + timestamp_result = await sandbox.commands.run( + "date -u -d '1 second ago' +%Y-%m-%dT%H:%M:%S" + ) + if timestamp_result.exit_code != 0: + raise RuntimeError( + f"Failed to capture timestamp: {timestamp_result.stderr}" + ) + start_timestamp = ( + timestamp_result.stdout.strip() if timestamp_result.stdout else None + ) + + result = await sandbox.commands.run( + claude_command, + timeout=0, # No command timeout - let sandbox timeout handle it + ) + + # Check for command failure + if result.exit_code != 0: + error_msg = result.stderr or result.stdout or "Unknown error" + raise Exception( + f"Claude Code command failed with exit code {result.exit_code}:\n" + f"{error_msg}" + ) + + raw_output = result.stdout or "" + + # Parse JSON output to extract response and build conversation history + response = "" + new_conversation_history = conversation_history or "" + + try: + # The JSON output contains the result + output_data = json.loads(raw_output) + response = output_data.get("result", raw_output) + + # Build conversation history entry + turn_entry = f"User: {prompt}\nClaude: {response}" + if new_conversation_history: + new_conversation_history = ( + f"{new_conversation_history}\n\n{turn_entry}" + ) + else: + new_conversation_history = turn_entry + + except json.JSONDecodeError: + # If not valid JSON, use raw output + response = raw_output + turn_entry = f"User: {prompt}\nClaude: {response}" + if new_conversation_history: + new_conversation_history = ( + f"{new_conversation_history}\n\n{turn_entry}" + ) + else: + new_conversation_history = turn_entry + + # Extract files created/modified during this run + files = await self._extract_files( + sandbox, working_directory, start_timestamp + ) + + return ( + response, + files, + new_conversation_history, + current_session_id, + sandbox_id, + ) + + except Exception as e: + # Wrap exception with sandbox_id so caller can access/cleanup + # the preserved sandbox when dispose_sandbox=False + raise ClaudeCodeExecutionError(str(e), sandbox_id) from e + + finally: + if dispose_sandbox and sandbox: + await sandbox.kill() + + async def _extract_files( + self, + sandbox: BaseAsyncSandbox, + working_directory: str, + since_timestamp: str | None = None, + ) -> list["ClaudeCodeBlock.FileOutput"]: + """ + Extract text files created/modified during this Claude Code execution. + + Args: + sandbox: The E2B sandbox instance + working_directory: Directory to search for files + since_timestamp: ISO timestamp - only return files modified after this time + + Returns: + List of FileOutput objects with path, relative_path, name, and content + """ + files: list[ClaudeCodeBlock.FileOutput] = [] + + # Text file extensions we can safely read as text + text_extensions = { + ".txt", + ".md", + ".html", + ".htm", + ".css", + ".js", + ".ts", + ".jsx", + ".tsx", + ".json", + ".xml", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".py", + ".rb", + ".php", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".cs", + ".go", + ".rs", + ".swift", + ".kt", + ".scala", + ".sh", + ".bash", + ".zsh", + ".sql", + ".graphql", + ".env", + ".gitignore", + ".dockerfile", + "Dockerfile", + ".vue", + ".svelte", + ".astro", + ".mdx", + ".rst", + ".tex", + ".csv", + ".log", + } + + try: + # List files recursively using find command + # Exclude node_modules and .git directories, but allow hidden files + # like .env and .gitignore (they're filtered by text_extensions later) + # Filter by timestamp to only get files created/modified during this run + safe_working_dir = shlex.quote(working_directory) + timestamp_filter = "" + if since_timestamp: + timestamp_filter = f"-newermt {shlex.quote(since_timestamp)} " + find_result = await sandbox.commands.run( + f"find {safe_working_dir} -type f " + f"{timestamp_filter}" + f"-not -path '*/node_modules/*' " + f"-not -path '*/.git/*' " + f"2>/dev/null" + ) + + if find_result.stdout: + for file_path in find_result.stdout.strip().split("\n"): + if not file_path: + continue + + # Check if it's a text file we can read + is_text = any( + file_path.endswith(ext) for ext in text_extensions + ) or file_path.endswith("Dockerfile") + + if is_text: + try: + content = await sandbox.files.read(file_path) + # Handle bytes or string + if isinstance(content, bytes): + content = content.decode("utf-8", errors="replace") + + # Extract filename from path + file_name = file_path.split("/")[-1] + + # Calculate relative path by stripping working directory + relative_path = file_path + if file_path.startswith(working_directory): + relative_path = file_path[len(working_directory) :] + # Remove leading slash if present + if relative_path.startswith("/"): + relative_path = relative_path[1:] + + files.append( + ClaudeCodeBlock.FileOutput( + path=file_path, + relative_path=relative_path, + name=file_name, + content=content, + ) + ) + except Exception: + # Skip files that can't be read + pass + + except Exception: + # If file extraction fails, return empty results + pass + + return files + + def _escape_prompt(self, prompt: str) -> str: + """Escape the prompt for safe shell execution.""" + # Use single quotes and escape any single quotes in the prompt + escaped = prompt.replace("'", "'\"'\"'") + return f"'{escaped}'" + + async def run( + self, + input_data: Input, + *, + e2b_credentials: APIKeyCredentials, + anthropic_credentials: APIKeyCredentials, + **kwargs, + ) -> BlockOutput: + try: + ( + response, + files, + conversation_history, + session_id, + sandbox_id, + ) = await self.execute_claude_code( + e2b_api_key=e2b_credentials.api_key.get_secret_value(), + anthropic_api_key=anthropic_credentials.api_key.get_secret_value(), + prompt=input_data.prompt, + timeout=input_data.timeout, + setup_commands=input_data.setup_commands, + working_directory=input_data.working_directory, + session_id=input_data.session_id, + existing_sandbox_id=input_data.sandbox_id, + conversation_history=input_data.conversation_history, + dispose_sandbox=input_data.dispose_sandbox, + ) + + yield "response", response + # Always yield files (empty list if none) to match Output schema + yield "files", [f.model_dump() for f in files] + # Always yield conversation_history so user can restore context on fresh sandbox + yield "conversation_history", conversation_history + # Always yield session_id so user can continue conversation + yield "session_id", session_id + # Always yield sandbox_id (None if disposed) to match Output schema + yield "sandbox_id", sandbox_id if not input_data.dispose_sandbox else None + + except ClaudeCodeExecutionError as e: + yield "error", str(e) + # If sandbox was preserved (dispose_sandbox=False), yield sandbox_id + # so user can reconnect to or clean up the orphaned sandbox + if not input_data.dispose_sandbox and e.sandbox_id: + yield "sandbox_id", e.sandbox_id + except Exception as e: + yield "error", str(e) diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 54e7d7feea..192405156c 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -216,6 +216,7 @@ Below is a comprehensive list of all available blocks, categorized by their prim | [AI Text Summarizer](block-integrations/llm.md#ai-text-summarizer) | A block that summarizes long texts using a Large Language Model (LLM), with configurable focus topics and summary styles | | [AI Video Generator](block-integrations/fal/ai_video_generator.md#ai-video-generator) | Generate videos using FAL AI models | | [Bannerbear Text Overlay](block-integrations/bannerbear/text_overlay.md#bannerbear-text-overlay) | Add text overlay to images using Bannerbear templates | +| [Claude Code](block-integrations/llm.md#claude-code) | Execute tasks using Claude Code in an E2B sandbox | | [Code Generation](block-integrations/llm.md#code-generation) | Generate or refactor code using OpenAI's Codex (Responses API) | | [Create Talking Avatar Video](block-integrations/llm.md#create-talking-avatar-video) | This block integrates with D-ID to create video clips and retrieve their URLs | | [Exa Answer](block-integrations/exa/answers.md#exa-answer) | Get an LLM answer to a question informed by Exa search results | diff --git a/docs/integrations/block-integrations/claude_code.md b/docs/integrations/block-integrations/claude_code.md new file mode 100644 index 0000000000..fea67cb494 --- /dev/null +++ b/docs/integrations/block-integrations/claude_code.md @@ -0,0 +1,67 @@ +# Claude Code Execution + +## What it is +The Claude Code block executes complex coding tasks using Anthropic's Claude Code AI assistant in a secure E2B sandbox environment. + +## What it does +This block allows you to delegate coding tasks to Claude Code, which can autonomously create files, install packages, run commands, and build complete applications within a sandboxed environment. Claude Code can handle multi-step development tasks and maintain conversation context across multiple turns. + +## How it works +When activated, the block: +1. Creates or connects to an E2B sandbox (a secure, isolated Linux environment) +2. Installs the latest version of Claude Code in the sandbox +3. Optionally runs setup commands to prepare the environment +4. Executes your prompt using Claude Code, which can: + - Create and edit files + - Install dependencies (npm, pip, etc.) + - Run terminal commands + - Build and test applications +5. Extracts all text files created/modified during execution +6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks + +The block supports conversation continuation through three mechanisms: +- **Same sandbox continuation** (via `session_id` + `sandbox_id`): Resume on the same live sandbox +- **Fresh sandbox continuation** (via `conversation_history`): Restore context on a new sandbox if the previous one timed out +- **Dispose control** (`dispose_sandbox` flag): Keep sandbox alive for multi-turn conversations + +## Inputs +| Input | Description | +|-------|-------------| +| E2B Credentials | API key for the E2B platform to create the sandbox. Get one at [e2b.dev](https://e2b.dev/docs) | +| Anthropic Credentials | API key for Anthropic to power Claude Code. Get one at [Anthropic's website](https://console.anthropic.com) | +| Prompt | The task or instruction for Claude Code to execute. Claude Code can create files, install packages, run commands, and perform complex coding tasks | +| Timeout | Sandbox timeout in seconds (default: 300). Set higher for complex tasks. Note: Only applies when creating a new sandbox | +| Setup Commands | Optional shell commands to run before executing Claude Code (e.g., installing dependencies) | +| Working Directory | Working directory for Claude Code to operate in (default: /home/user) | +| Session ID | Session ID to resume a previous conversation. Leave empty for new conversations | +| Sandbox ID | Sandbox ID to reconnect to an existing sandbox. Required when resuming a session | +| Conversation History | Previous conversation history to restore context on a fresh sandbox if the previous one timed out | +| Dispose Sandbox | Whether to dispose of the sandbox after execution (default: true). Set to false to continue conversations later | + +## Outputs +| Output | Description | +|--------|-------------| +| Response | The output/response from Claude Code execution | +| Files | List of text files created/modified during execution. Each file includes path, relative_path, name, and content fields | +| Conversation History | Full conversation history including this turn. Use to restore context on a fresh sandbox | +| Session ID | Session ID for this conversation. Pass back with sandbox_id to continue the conversation | +| Sandbox ID | ID of the sandbox instance (null if disposed). Pass back with session_id to continue the conversation | +| Error | Error message if execution failed | + +## Possible use case +**API Documentation to Full Application:** +A product team wants to quickly prototype applications based on API documentation. They create an agent that: +1. Uses Firecrawl to fetch API documentation from a URL +2. Passes the docs to Claude Code with a prompt like "Create a web app that demonstrates all the key features of this API" +3. Claude Code builds a complete application with HTML/CSS/JS frontend, proper error handling, and example API calls +4. The Files output is used with GitHub blocks to push the generated code to a new repository + +The team can then iterate on the application by passing the sandbox_id and session_id back to Claude Code with refinement requests like "Add authentication" or "Improve the UI", and Claude Code will modify the existing files in the same sandbox. + +**Multi-turn Development:** +A developer uses Claude Code to scaffold a new project: +- Turn 1: "Create a Python FastAPI project with user authentication" (dispose_sandbox=false) +- Turn 2: Uses the returned session_id + sandbox_id to ask "Add rate limiting middleware" +- Turn 3: Continues with "Add comprehensive tests" + +Each turn builds on the previous work in the same sandbox environment. diff --git a/docs/integrations/block-integrations/llm.md b/docs/integrations/block-integrations/llm.md index 4e75714e56..f4d69b912b 100644 --- a/docs/integrations/block-integrations/llm.md +++ b/docs/integrations/block-integrations/llm.md @@ -523,6 +523,62 @@ Summarizing lengthy research papers or articles to quickly grasp the main points --- +## Claude Code + +### What it is +Execute tasks using Claude Code in an E2B sandbox. Claude Code can create files, install tools, run commands, and perform complex coding tasks autonomously. + +### How it works + +When activated, the block: +1. Creates or connects to an E2B sandbox (a secure, isolated Linux environment) +2. Installs the latest version of Claude Code in the sandbox +3. Optionally runs setup commands to prepare the environment +4. Executes your prompt using Claude Code, which can create/edit files, install dependencies, run terminal commands, and build applications +5. Extracts all text files created/modified during execution +6. Returns the response and files, optionally keeping the sandbox alive for follow-up tasks + +The block supports conversation continuation through three mechanisms: +- **Same sandbox continuation** (via `session_id` + `sandbox_id`): Resume on the same live sandbox +- **Fresh sandbox continuation** (via `conversation_history`): Restore context on a new sandbox if the previous one timed out +- **Dispose control** (`dispose_sandbox` flag): Keep sandbox alive for multi-turn conversations + + +### Inputs + +| Input | Description | Type | Required | +|-------|-------------|------|----------| +| prompt | The task or instruction for Claude Code to execute. Claude Code can create files, install packages, run commands, and perform complex coding tasks. | str | No | +| timeout | Sandbox timeout in seconds. Claude Code tasks can take a while, so set this appropriately for your task complexity. Note: This only applies when creating a new sandbox. When reconnecting to an existing sandbox via sandbox_id, the original timeout is retained. | int | No | +| setup_commands | Optional shell commands to run before executing Claude Code. Useful for installing dependencies or setting up the environment. | List[str] | No | +| working_directory | Working directory for Claude Code to operate in. | str | No | +| session_id | Session ID to resume a previous conversation. Leave empty for a new conversation. Use the session_id from a previous run to continue that conversation. | str | No | +| sandbox_id | Sandbox ID to reconnect to an existing sandbox. Required when resuming a session (along with session_id). Use the sandbox_id from a previous run where dispose_sandbox was False. | str | No | +| conversation_history | Previous conversation history to continue from. Use this to restore context on a fresh sandbox if the previous one timed out. Pass the conversation_history output from a previous run. | str | No | +| dispose_sandbox | Whether to dispose of the sandbox immediately after execution. Set to False if you want to continue the conversation later (you'll need both sandbox_id and session_id from the output). | bool | No | + +### Outputs + +| Output | Description | Type | +|--------|-------------|------| +| error | Error message if execution failed | str | +| response | The output/response from Claude Code execution | str | +| files | List of text files created/modified by Claude Code during this execution. Each file has 'path', 'relative_path', 'name', and 'content' fields. | List[FileOutput] | +| conversation_history | Full conversation history including this turn. Pass this to conversation_history input to continue on a fresh sandbox if the previous sandbox timed out. | str | +| session_id | Session ID for this conversation. Pass this back along with sandbox_id to continue the conversation. | str | +| sandbox_id | ID of the sandbox instance. Pass this back along with session_id to continue the conversation. This is None if dispose_sandbox was True (sandbox was disposed). | str | + +### Possible use case + +**API Documentation to Full Application**: A product team wants to quickly prototype applications based on API documentation. They fetch API docs with Firecrawl, pass them to Claude Code with a prompt like "Create a web app that demonstrates all the key features of this API", and Claude Code builds a complete application with HTML/CSS/JS frontend, proper error handling, and example API calls. The Files output can then be pushed to GitHub. + +**Multi-turn Development**: A developer uses Claude Code to scaffold a new project iteratively - Turn 1: "Create a Python FastAPI project with user authentication" (dispose_sandbox=false), Turn 2: Uses the returned session_id + sandbox_id to ask "Add rate limiting middleware", Turn 3: Continues with "Add comprehensive tests". Each turn builds on the previous work in the same sandbox environment. + +**Automated Code Review and Fixes**: An agent receives code from a PR, sends it to Claude Code with "Review this code for bugs and security issues, then fix any problems you find", and Claude Code analyzes the code, makes fixes, and returns the corrected files ready to commit. + + +--- + ## Code Generation ### What it is From 7892590b12c07956dcb603af0cbbcbc7615859a5 Mon Sep 17 00:00:00 2001 From: Ubbe Date: Fri, 23 Jan 2026 18:25:45 +0700 Subject: [PATCH 04/36] feat(frontend): refine copilot loading states (#11827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes šŸ—ļø - Make the loading UX better when switching between chats or loading a new chat - Make session/chat management logic more manageable - Improving "Deep thinking" loading states - Fix bug that happened when returning to chat after navigating away ## Checklist šŸ“‹ ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Run the app locally and test the above --- .../app/(platform)/copilot/NewChatContext.tsx | 41 +++ .../components/CopilotShell/CopilotShell.tsx | 25 +- .../CopilotShell/useCopilotShell.ts | 4 +- .../src/app/(platform)/copilot/helpers.ts | 23 ++ .../src/app/(platform)/copilot/layout.tsx | 7 +- .../src/app/(platform)/copilot/page.tsx | 213 +++++--------- .../app/(platform)/copilot/useCopilotPage.ts | 266 ++++++++++++++++++ .../(platform)/copilot/useCopilotURLState.ts | 80 ++++++ .../src/components/contextual/Chat/Chat.tsx | 3 + .../ChatContainer/ChatContainer.tsx | 7 + .../Chat/components/ChatLoader/ChatLoader.tsx | 11 +- .../components/ChatMessage/ChatMessage.tsx | 18 +- .../ThinkingMessage/ThinkingMessage.tsx | 31 +- autogpt_platform/frontend/tailwind.config.ts | 9 + 14 files changed, 576 insertions(+), 162 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/NewChatContext.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotURLState.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/NewChatContext.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/NewChatContext.tsx new file mode 100644 index 0000000000..0826637043 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/NewChatContext.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { createContext, useContext, useRef, type ReactNode } from "react"; + +interface NewChatContextValue { + onNewChatClick: () => void; + setOnNewChatClick: (handler?: () => void) => void; + performNewChat?: () => void; + setPerformNewChat: (handler?: () => void) => void; +} + +const NewChatContext = createContext(null); + +export function NewChatProvider({ children }: { children: ReactNode }) { + const onNewChatRef = useRef<(() => void) | undefined>(); + const performNewChatRef = useRef<(() => void) | undefined>(); + const contextValueRef = useRef({ + onNewChatClick() { + onNewChatRef.current?.(); + }, + setOnNewChatClick(handler?: () => void) { + onNewChatRef.current = handler; + }, + performNewChat() { + performNewChatRef.current?.(); + }, + setPerformNewChat(handler?: () => void) { + performNewChatRef.current = handler; + }, + }); + + return ( + + {children} + + ); +} + +export function useNewChat() { + return useContext(NewChatContext); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx index 03a2ff5db0..44e32024a8 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx @@ -1,8 +1,10 @@ "use client"; -import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; +import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader"; import { NAVBAR_HEIGHT_PX } from "@/lib/constants"; import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { useNewChat } from "../../NewChatContext"; import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar"; import { LoadingState } from "./components/LoadingState/LoadingState"; import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer"; @@ -33,10 +35,25 @@ export function CopilotShell({ children }: Props) { isReadyToShowContent, } = useCopilotShell(); + const newChatContext = useNewChat(); + const handleNewChatClickWrapper = + newChatContext?.onNewChatClick || handleNewChat; + + useEffect( + function registerNewChatHandler() { + if (!newChatContext) return; + newChatContext.setPerformNewChat(handleNewChat); + return function cleanup() { + newChatContext.setPerformNewChat(undefined); + }; + }, + [newChatContext, handleNewChat], + ); + if (!isLoggedIn) { return (

); } @@ -55,7 +72,7 @@ export function CopilotShell({ children }: Props) { isFetchingNextPage={isFetchingNextPage} onSelectSession={handleSelectSession} onFetchNextPage={fetchNextPage} - onNewChat={handleNewChat} + onNewChat={handleNewChatClickWrapper} hasActiveSession={Boolean(hasActiveSession)} /> )} @@ -77,7 +94,7 @@ export function CopilotShell({ children }: Props) { isFetchingNextPage={isFetchingNextPage} onSelectSession={handleSelectSession} onFetchNextPage={fetchNextPage} - onNewChat={handleNewChat} + onNewChat={handleNewChatClickWrapper} onClose={handleCloseDrawer} onOpenChange={handleDrawerOpenChange} hasActiveSession={Boolean(hasActiveSession)} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts index 6003c64b73..cadd98da3e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts @@ -148,13 +148,15 @@ export function useCopilotShell() { setHasAutoSelectedSession(false); } + const isLoading = isSessionsLoading && accumulatedSessions.length === 0; + return { isMobile, isDrawerOpen, isLoggedIn, hasActiveSession: Boolean(currentSessionId) && (!isOnHomepage || Boolean(paramSessionId)), - isLoading: isSessionsLoading || !areAllSessionsLoaded, + isLoading, sessions: visibleSessions, currentSessionId: sidebarSelectedSessionId, handleSelectSession, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts index 692a5741f4..a5818f0a9f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts @@ -1,5 +1,28 @@ import type { User } from "@supabase/supabase-js"; +export type PageState = + | { type: "welcome" } + | { type: "newChat" } + | { type: "creating"; prompt: string } + | { type: "chat"; sessionId: string; initialPrompt?: string }; + +export function getInitialPromptFromState( + pageState: PageState, + storedInitialPrompt: string | undefined, +) { + if (storedInitialPrompt) return storedInitialPrompt; + if (pageState.type === "creating") return pageState.prompt; + if (pageState.type === "chat") return pageState.initialPrompt; +} + +export function shouldResetToWelcome(pageState: PageState) { + return ( + pageState.type !== "newChat" && + pageState.type !== "creating" && + pageState.type !== "welcome" + ); +} + export function getGreetingName(user?: User | null): string { if (!user) return "there"; const metadata = user.user_metadata as Record | undefined; diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx index 89cf72e2ba..0f40de8f25 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx @@ -1,6 +1,11 @@ import type { ReactNode } from "react"; +import { NewChatProvider } from "./NewChatContext"; import { CopilotShell } from "./components/CopilotShell/CopilotShell"; export default function CopilotLayout({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx index add9504f9b..3bbafd087b 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx @@ -1,142 +1,35 @@ "use client"; -import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat"; import { Skeleton } from "@/components/__legacy__/ui/skeleton"; import { Button } from "@/components/atoms/Button/Button"; -import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; import { Text } from "@/components/atoms/Text/Text"; import { Chat } from "@/components/contextual/Chat/Chat"; import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput"; -import { getHomepageRoute } from "@/lib/constants"; -import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; -import { - Flag, - type FlagValues, - useGetFlag, -} from "@/services/feature-flags/use-get-flag"; -import { useFlags } from "launchdarkly-react-client-sdk"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { getGreetingName, getQuickActions } from "./helpers"; - -type PageState = - | { type: "welcome" } - | { type: "creating"; prompt: string } - | { type: "chat"; sessionId: string; initialPrompt?: string }; +import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader"; +import { Dialog } from "@/components/molecules/Dialog/Dialog"; +import { useCopilotPage } from "./useCopilotPage"; export default function CopilotPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { user, isLoggedIn, isUserLoading } = useSupabase(); + const { state, handlers } = useCopilotPage(); + const { + greetingName, + quickActions, + isLoading, + pageState, + isNewChatModalOpen, + isReady, + } = state; + const { + handleQuickAction, + startChatWithPrompt, + handleSessionNotFound, + handleStreamingChange, + handleCancelNewChat, + proceedWithNewChat, + handleNewChatModalOpen, + } = handlers; - const isChatEnabled = useGetFlag(Flag.CHAT); - const flags = useFlags(); - const homepageRoute = getHomepageRoute(isChatEnabled); - const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true"; - const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID; - const isLaunchDarklyConfigured = envEnabled && Boolean(clientId); - const isFlagReady = - !isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined; - - const [pageState, setPageState] = useState({ type: "welcome" }); - const initialPromptRef = useRef>(new Map()); - - const urlSessionId = searchParams.get("sessionId"); - - // Sync with URL sessionId (preserve initialPrompt from ref) - useEffect( - function syncSessionFromUrl() { - if (urlSessionId) { - // If we're already in chat state with this sessionId, don't overwrite - if (pageState.type === "chat" && pageState.sessionId === urlSessionId) { - return; - } - // Get initialPrompt from ref or current state - const storedInitialPrompt = initialPromptRef.current.get(urlSessionId); - const currentInitialPrompt = - storedInitialPrompt || - (pageState.type === "creating" - ? pageState.prompt - : pageState.type === "chat" - ? pageState.initialPrompt - : undefined); - if (currentInitialPrompt) { - initialPromptRef.current.set(urlSessionId, currentInitialPrompt); - } - setPageState({ - type: "chat", - sessionId: urlSessionId, - initialPrompt: currentInitialPrompt, - }); - } else if (pageState.type === "chat") { - setPageState({ type: "welcome" }); - } - }, - [urlSessionId], - ); - - useEffect( - function ensureAccess() { - if (!isFlagReady) return; - if (isChatEnabled === false) { - router.replace(homepageRoute); - } - }, - [homepageRoute, isChatEnabled, isFlagReady, router], - ); - - const greetingName = useMemo( - function getName() { - return getGreetingName(user); - }, - [user], - ); - - const quickActions = useMemo(function getActions() { - return getQuickActions(); - }, []); - - async function startChatWithPrompt(prompt: string) { - if (!prompt?.trim()) return; - if (pageState.type === "creating") return; - - const trimmedPrompt = prompt.trim(); - setPageState({ type: "creating", prompt: trimmedPrompt }); - - try { - // Create session - const sessionResponse = await postV2CreateSession({ - body: JSON.stringify({}), - }); - - if (sessionResponse.status !== 200 || !sessionResponse.data?.id) { - throw new Error("Failed to create session"); - } - - const sessionId = sessionResponse.data.id; - - // Store initialPrompt in ref so it persists across re-renders - initialPromptRef.current.set(sessionId, trimmedPrompt); - - // Update URL and show Chat with initial prompt - // Chat will handle sending the message and streaming - window.history.replaceState(null, "", `/copilot?sessionId=${sessionId}`); - setPageState({ type: "chat", sessionId, initialPrompt: trimmedPrompt }); - } catch (error) { - console.error("[CopilotPage] Failed to start chat:", error); - setPageState({ type: "welcome" }); - } - } - - function handleQuickAction(action: string) { - startChatWithPrompt(action); - } - - function handleSessionNotFound() { - router.replace("/copilot"); - } - - if (!isFlagReady || isChatEnabled === false || !isLoggedIn) { + if (!isReady) { return null; } @@ -150,7 +43,55 @@ export default function CopilotPage() { urlSessionId={pageState.sessionId} initialPrompt={pageState.initialPrompt} onSessionNotFound={handleSessionNotFound} + onStreamingChange={handleStreamingChange} /> + + +
+ + The current chat response will be interrupted. Are you sure you + want to start a new chat? + + + + + +
+
+
+ + ); + } + + if (pageState.type === "newChat") { + return ( +
+
+ + + Loading your chats... + +
); } @@ -158,18 +99,18 @@ export default function CopilotPage() { // Show loading state while creating session and sending first message if (pageState.type === "creating") { return ( -
- - - Starting your chat... - +
+
+ + + Loading your chats... + +
); } // Show Welcome screen - const isLoading = isUserLoading; - return (
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts new file mode 100644 index 0000000000..cb13137432 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -0,0 +1,266 @@ +import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat"; +import { useToast } from "@/components/molecules/Toast/use-toast"; +import { getHomepageRoute } from "@/lib/constants"; +import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; +import { + Flag, + type FlagValues, + useGetFlag, +} from "@/services/feature-flags/use-get-flag"; +import * as Sentry from "@sentry/nextjs"; +import { useFlags } from "launchdarkly-react-client-sdk"; +import { useRouter } from "next/navigation"; +import { useEffect, useReducer } from "react"; +import { useNewChat } from "./NewChatContext"; +import { getGreetingName, getQuickActions, type PageState } from "./helpers"; +import { useCopilotURLState } from "./useCopilotURLState"; + +type CopilotState = { + pageState: PageState; + isStreaming: boolean; + isNewChatModalOpen: boolean; + initialPrompts: Record; + previousSessionId: string | null; +}; + +type CopilotAction = + | { type: "setPageState"; pageState: PageState } + | { type: "setStreaming"; isStreaming: boolean } + | { type: "setNewChatModalOpen"; isOpen: boolean } + | { type: "setInitialPrompt"; sessionId: string; prompt: string } + | { type: "setPreviousSessionId"; sessionId: string | null }; + +function isSamePageState(next: PageState, current: PageState) { + if (next.type !== current.type) return false; + if (next.type === "creating" && current.type === "creating") { + return next.prompt === current.prompt; + } + if (next.type === "chat" && current.type === "chat") { + return ( + next.sessionId === current.sessionId && + next.initialPrompt === current.initialPrompt + ); + } + return true; +} + +function copilotReducer( + state: CopilotState, + action: CopilotAction, +): CopilotState { + if (action.type === "setPageState") { + if (isSamePageState(action.pageState, state.pageState)) return state; + return { ...state, pageState: action.pageState }; + } + if (action.type === "setStreaming") { + if (action.isStreaming === state.isStreaming) return state; + return { ...state, isStreaming: action.isStreaming }; + } + if (action.type === "setNewChatModalOpen") { + if (action.isOpen === state.isNewChatModalOpen) return state; + return { ...state, isNewChatModalOpen: action.isOpen }; + } + if (action.type === "setInitialPrompt") { + if (state.initialPrompts[action.sessionId] === action.prompt) return state; + return { + ...state, + initialPrompts: { + ...state.initialPrompts, + [action.sessionId]: action.prompt, + }, + }; + } + if (action.type === "setPreviousSessionId") { + if (state.previousSessionId === action.sessionId) return state; + return { ...state, previousSessionId: action.sessionId }; + } + return state; +} + +export function useCopilotPage() { + const router = useRouter(); + const { user, isLoggedIn, isUserLoading } = useSupabase(); + const { toast } = useToast(); + + const isChatEnabled = useGetFlag(Flag.CHAT); + const flags = useFlags(); + const homepageRoute = getHomepageRoute(isChatEnabled); + const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true"; + const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID; + const isLaunchDarklyConfigured = envEnabled && Boolean(clientId); + const isFlagReady = + !isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined; + + const [state, dispatch] = useReducer(copilotReducer, { + pageState: { type: "welcome" }, + isStreaming: false, + isNewChatModalOpen: false, + initialPrompts: {}, + previousSessionId: null, + }); + + const newChatContext = useNewChat(); + const greetingName = getGreetingName(user); + const quickActions = getQuickActions(); + + function setPageState(pageState: PageState) { + dispatch({ type: "setPageState", pageState }); + } + + function setInitialPrompt(sessionId: string, prompt: string) { + dispatch({ type: "setInitialPrompt", sessionId, prompt }); + } + + function setPreviousSessionId(sessionId: string | null) { + dispatch({ type: "setPreviousSessionId", sessionId }); + } + + const { setUrlSessionId } = useCopilotURLState({ + pageState: state.pageState, + initialPrompts: state.initialPrompts, + previousSessionId: state.previousSessionId, + setPageState, + setInitialPrompt, + setPreviousSessionId, + }); + + useEffect( + function registerNewChatHandler() { + if (!newChatContext) return; + newChatContext.setOnNewChatClick(handleNewChatClick); + return function cleanup() { + newChatContext.setOnNewChatClick(undefined); + }; + }, + [newChatContext, handleNewChatClick], + ); + + useEffect( + function transitionNewChatToWelcome() { + if (state.pageState.type === "newChat") { + function setWelcomeState() { + dispatch({ type: "setPageState", pageState: { type: "welcome" } }); + } + + const timer = setTimeout(setWelcomeState, 300); + + return function cleanup() { + clearTimeout(timer); + }; + } + }, + [state.pageState.type], + ); + + useEffect( + function ensureAccess() { + if (!isFlagReady) return; + if (isChatEnabled === false) { + router.replace(homepageRoute); + } + }, + [homepageRoute, isChatEnabled, isFlagReady, router], + ); + + async function startChatWithPrompt(prompt: string) { + if (!prompt?.trim()) return; + if (state.pageState.type === "creating") return; + + const trimmedPrompt = prompt.trim(); + dispatch({ + type: "setPageState", + pageState: { type: "creating", prompt: trimmedPrompt }, + }); + + try { + const sessionResponse = await postV2CreateSession({ + body: JSON.stringify({}), + }); + + if (sessionResponse.status !== 200 || !sessionResponse.data?.id) { + throw new Error("Failed to create session"); + } + + const sessionId = sessionResponse.data.id; + + dispatch({ + type: "setInitialPrompt", + sessionId, + prompt: trimmedPrompt, + }); + + await setUrlSessionId(sessionId, { shallow: false }); + dispatch({ + type: "setPageState", + pageState: { type: "chat", sessionId, initialPrompt: trimmedPrompt }, + }); + } catch (error) { + console.error("[CopilotPage] Failed to start chat:", error); + toast({ title: "Failed to start chat", variant: "destructive" }); + Sentry.captureException(error); + dispatch({ type: "setPageState", pageState: { type: "welcome" } }); + } + } + + function handleQuickAction(action: string) { + startChatWithPrompt(action); + } + + function handleSessionNotFound() { + router.replace("/copilot"); + } + + function handleStreamingChange(isStreamingValue: boolean) { + dispatch({ type: "setStreaming", isStreaming: isStreamingValue }); + } + + async function proceedWithNewChat() { + dispatch({ type: "setNewChatModalOpen", isOpen: false }); + if (newChatContext?.performNewChat) { + newChatContext.performNewChat(); + return; + } + try { + await setUrlSessionId(null, { shallow: false }); + } catch (error) { + console.error("[CopilotPage] Failed to clear session:", error); + } + router.replace("/copilot"); + } + + function handleCancelNewChat() { + dispatch({ type: "setNewChatModalOpen", isOpen: false }); + } + + function handleNewChatModalOpen(isOpen: boolean) { + dispatch({ type: "setNewChatModalOpen", isOpen }); + } + + function handleNewChatClick() { + if (state.isStreaming) { + dispatch({ type: "setNewChatModalOpen", isOpen: true }); + } else { + proceedWithNewChat(); + } + } + + return { + state: { + greetingName, + quickActions, + isLoading: isUserLoading, + pageState: state.pageState, + isNewChatModalOpen: state.isNewChatModalOpen, + isReady: isFlagReady && isChatEnabled !== false && isLoggedIn, + }, + handlers: { + handleQuickAction, + startChatWithPrompt, + handleSessionNotFound, + handleStreamingChange, + handleCancelNewChat, + proceedWithNewChat, + handleNewChatModalOpen, + }, + }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotURLState.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotURLState.ts new file mode 100644 index 0000000000..5e37e29a15 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotURLState.ts @@ -0,0 +1,80 @@ +import { parseAsString, useQueryState } from "nuqs"; +import { useLayoutEffect } from "react"; +import { + getInitialPromptFromState, + type PageState, + shouldResetToWelcome, +} from "./helpers"; + +interface UseCopilotUrlStateArgs { + pageState: PageState; + initialPrompts: Record; + previousSessionId: string | null; + setPageState: (pageState: PageState) => void; + setInitialPrompt: (sessionId: string, prompt: string) => void; + setPreviousSessionId: (sessionId: string | null) => void; +} + +export function useCopilotURLState({ + pageState, + initialPrompts, + previousSessionId, + setPageState, + setInitialPrompt, + setPreviousSessionId, +}: UseCopilotUrlStateArgs) { + const [urlSessionId, setUrlSessionId] = useQueryState( + "sessionId", + parseAsString, + ); + + function syncSessionFromUrl() { + if (urlSessionId) { + if (pageState.type === "chat" && pageState.sessionId === urlSessionId) { + setPreviousSessionId(urlSessionId); + return; + } + + const storedInitialPrompt = initialPrompts[urlSessionId]; + const currentInitialPrompt = getInitialPromptFromState( + pageState, + storedInitialPrompt, + ); + + if (currentInitialPrompt) { + setInitialPrompt(urlSessionId, currentInitialPrompt); + } + + setPageState({ + type: "chat", + sessionId: urlSessionId, + initialPrompt: currentInitialPrompt, + }); + setPreviousSessionId(urlSessionId); + return; + } + + const wasInChat = previousSessionId !== null && pageState.type === "chat"; + setPreviousSessionId(null); + if (wasInChat) { + setPageState({ type: "newChat" }); + return; + } + + if (shouldResetToWelcome(pageState)) { + setPageState({ type: "welcome" }); + } + } + + useLayoutEffect(syncSessionFromUrl, [ + urlSessionId, + pageState.type, + previousSessionId, + initialPrompts, + ]); + + return { + urlSessionId, + setUrlSessionId, + }; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx index 0f99246088..ba7584765d 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx @@ -13,6 +13,7 @@ export interface ChatProps { urlSessionId?: string | null; initialPrompt?: string; onSessionNotFound?: () => void; + onStreamingChange?: (isStreaming: boolean) => void; } export function Chat({ @@ -20,6 +21,7 @@ export function Chat({ urlSessionId, initialPrompt, onSessionNotFound, + onStreamingChange, }: ChatProps) { const hasHandledNotFoundRef = useRef(false); const { @@ -73,6 +75,7 @@ export function Chat({ initialMessages={messages} initialPrompt={initialPrompt} className="flex-1" + onStreamingChange={onStreamingChange} /> )} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx index b86f1c922a..17748f8dbc 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx @@ -4,6 +4,7 @@ import { Text } from "@/components/atoms/Text/Text"; import { Dialog } from "@/components/molecules/Dialog/Dialog"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { cn } from "@/lib/utils"; +import { useEffect } from "react"; import { ChatInput } from "../ChatInput/ChatInput"; import { MessageList } from "../MessageList/MessageList"; import { useChatContainer } from "./useChatContainer"; @@ -13,6 +14,7 @@ export interface ChatContainerProps { initialMessages: SessionDetailResponse["messages"]; initialPrompt?: string; className?: string; + onStreamingChange?: (isStreaming: boolean) => void; } export function ChatContainer({ @@ -20,6 +22,7 @@ export function ChatContainer({ initialMessages, initialPrompt, className, + onStreamingChange, }: ChatContainerProps) { const { messages, @@ -36,6 +39,10 @@ export function ChatContainer({ initialPrompt, }); + useEffect(() => { + onStreamingChange?.(isStreaming); + }, [isStreaming, onStreamingChange]); + const breakpoint = useBreakpoint(); const isMobile = breakpoint === "base" || breakpoint === "sm" || breakpoint === "md"; diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatLoader/ChatLoader.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatLoader/ChatLoader.tsx index 057a795fce..76cee8dbae 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatLoader/ChatLoader.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatLoader/ChatLoader.tsx @@ -1,12 +1,7 @@ -import { Text } from "@/components/atoms/Text/Text"; - export function ChatLoader() { return ( - - Taking a bit more time... - +
+
+
); } diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx index 620ced9280..a2827ce611 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx @@ -7,7 +7,6 @@ import { ArrowsClockwiseIcon, CheckCircleIcon, CheckIcon, - CopyIcon, } from "@phosphor-icons/react"; import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; @@ -340,11 +339,26 @@ export function ChatMessage({ size="icon" onClick={handleCopy} aria-label="Copy message" + className="p-1" > {copied ? ( ) : ( - + + + + )} )} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ThinkingMessage/ThinkingMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ThinkingMessage/ThinkingMessage.tsx index 9903f6453d..047c2277b0 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ThinkingMessage/ThinkingMessage.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ThinkingMessage/ThinkingMessage.tsx @@ -1,7 +1,6 @@ import { cn } from "@/lib/utils"; import { useEffect, useRef, useState } from "react"; import { AIChatBubble } from "../AIChatBubble/AIChatBubble"; -import { ChatLoader } from "../ChatLoader/ChatLoader"; export interface ThinkingMessageProps { className?: string; @@ -9,7 +8,9 @@ export interface ThinkingMessageProps { export function ThinkingMessage({ className }: ThinkingMessageProps) { const [showSlowLoader, setShowSlowLoader] = useState(false); + const [showCoffeeMessage, setShowCoffeeMessage] = useState(false); const timerRef = useRef(null); + const coffeeTimerRef = useRef(null); useEffect(() => { if (timerRef.current === null) { @@ -18,11 +19,21 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) { }, 8000); } + if (coffeeTimerRef.current === null) { + coffeeTimerRef.current = setTimeout(() => { + setShowCoffeeMessage(true); + }, 10000); + } + return () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } + if (coffeeTimerRef.current) { + clearTimeout(coffeeTimerRef.current); + coffeeTimerRef.current = null; + } }; }, []); @@ -37,16 +48,16 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
- {showSlowLoader ? ( - + {showCoffeeMessage ? ( + + This could take a few minutes, grab a coffee ā˜•ļø + + ) : showSlowLoader ? ( + + Taking a bit more time... + ) : ( - + Thinking... )} diff --git a/autogpt_platform/frontend/tailwind.config.ts b/autogpt_platform/frontend/tailwind.config.ts index f560a5e3b9..1adc53bdc4 100644 --- a/autogpt_platform/frontend/tailwind.config.ts +++ b/autogpt_platform/frontend/tailwind.config.ts @@ -157,12 +157,21 @@ const config = { backgroundPosition: "-200% 0", }, }, + loader: { + "0%": { + boxShadow: "0 0 0 0 rgba(0, 0, 0, 0.25)", + }, + "100%": { + boxShadow: "0 0 0 30px rgba(0, 0, 0, 0)", + }, + }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", "fade-in": "fade-in 0.2s ease-out", shimmer: "shimmer 2s ease-in-out infinite", + loader: "loader 1s infinite", }, transitionDuration: { "2000": "2000ms", From 595f3508c1cabbd6f181d9b5a9c45ac2645e3e55 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Sat, 24 Jan 2026 09:49:32 -0500 Subject: [PATCH 05/36] refactor(backend): consolidate embedding error logging to prevent Sentry spam (#11832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Refactors error handling in the embedding service to prevent Sentry alert spam. Previously, batch operations would log one error per failed file, causing hundreds of duplicate alerts. Now, exceptions bubble up from individual functions and are aggregated at the batch level, producing a single log entry showing all unique error types with counts. ## Changes ### Removed Error Swallowing - Removed try/except blocks from `generate_embedding()`, `store_content_embedding()`, `ensure_content_embedding()`, `get_content_embedding()`, and `ensure_embedding()` - These functions now raise exceptions instead of returning None/False on failure - Added docstring notes: "Raises exceptions on failure - caller should handle" ### Improved Batch Error Aggregation - Updated `backfill_all_content_types()` to aggregate unique errors - Collects all exceptions from batch results - Groups by error type and message, shows counts - Single log entry per content type instead of per-file ### Example Output Before: 50 separate error logs for same issue After: `BLOCK: 50/100 embeddings failed. Errors: PrismaError: type vector does not exist (50x)` ## Motivation This was triggered by the AUTOGPT-SERVER-7D2 Sentry issue where pgvector errors created hundreds of duplicate alerts. Even after the root cause was fixed (stale database connections), the error logging pattern would create spam for any future issues. ## Impact - āœ… Reduces Sentry noise - single alert per batch instead of per-file - āœ… Better diagnostics - shows all unique error types with counts - āœ… Cleaner code - removed ~24 lines of unnecessary error swallowing - āœ… Proper exception propagation follows Python best practices ## Testing - Existing tests should pass (error handling moved to batch level) - Error aggregation logic tested via asyncio.gather(return_exceptions=True) ## Related Issues - Fixes Sentry alert spam from AUTOGPT-SERVER-7D2 --- .../backend/backend/api/features/store/db.py | 8 +- .../backend/api/features/store/embeddings.py | 354 +++++++++--------- .../features/store/embeddings_schema_test.py | 21 +- .../api/features/store/embeddings_test.py | 38 +- .../api/features/store/hybrid_search.py | 22 +- .../api/features/store/hybrid_search_test.py | 8 +- 6 files changed, 212 insertions(+), 239 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/store/db.py b/autogpt_platform/backend/backend/api/features/store/db.py index e6aa3853f6..956fdfa7da 100644 --- a/autogpt_platform/backend/backend/api/features/store/db.py +++ b/autogpt_platform/backend/backend/api/features/store/db.py @@ -1552,7 +1552,7 @@ async def review_store_submission( # Generate embedding for approved listing (blocking - admin operation) # Inside transaction: if embedding fails, entire transaction rolls back - embedding_success = await ensure_embedding( + await ensure_embedding( version_id=store_listing_version_id, name=store_listing_version.name, description=store_listing_version.description, @@ -1560,12 +1560,6 @@ async def review_store_submission( categories=store_listing_version.categories or [], tx=tx, ) - if not embedding_success: - raise ValueError( - f"Failed to generate embedding for listing {store_listing_version_id}. " - "This is likely due to OpenAI API being unavailable. " - "Please try again later or contact support if the issue persists." - ) await prisma.models.StoreListing.prisma(tx).update( where={"id": store_listing_version.StoreListing.id}, diff --git a/autogpt_platform/backend/backend/api/features/store/embeddings.py b/autogpt_platform/backend/backend/api/features/store/embeddings.py index efe896f665..3aa2f3bdbb 100644 --- a/autogpt_platform/backend/backend/api/features/store/embeddings.py +++ b/autogpt_platform/backend/backend/api/features/store/embeddings.py @@ -63,49 +63,42 @@ def build_searchable_text( return " ".join(parts) -async def generate_embedding(text: str) -> list[float] | None: +async def generate_embedding(text: str) -> list[float]: """ Generate embedding for text using OpenAI API. - Returns None if embedding generation fails. - Fail-fast: no retries to maintain consistency with approval flow. + Raises exceptions on failure - caller should handle. """ - try: - client = get_openai_client() - if not client: - logger.error("openai_internal_api_key not set, cannot generate embedding") - return None + client = get_openai_client() + if not client: + raise RuntimeError("openai_internal_api_key not set, cannot generate embedding") - # Truncate text to token limit using tiktoken - # Character-based truncation is insufficient because token ratios vary by content type - enc = encoding_for_model(EMBEDDING_MODEL) - tokens = enc.encode(text) - if len(tokens) > EMBEDDING_MAX_TOKENS: - tokens = tokens[:EMBEDDING_MAX_TOKENS] - truncated_text = enc.decode(tokens) - logger.info( - f"Truncated text from {len(enc.encode(text))} to {len(tokens)} tokens" - ) - else: - truncated_text = text - - start_time = time.time() - response = await client.embeddings.create( - model=EMBEDDING_MODEL, - input=truncated_text, - ) - latency_ms = (time.time() - start_time) * 1000 - - embedding = response.data[0].embedding + # Truncate text to token limit using tiktoken + # Character-based truncation is insufficient because token ratios vary by content type + enc = encoding_for_model(EMBEDDING_MODEL) + tokens = enc.encode(text) + if len(tokens) > EMBEDDING_MAX_TOKENS: + tokens = tokens[:EMBEDDING_MAX_TOKENS] + truncated_text = enc.decode(tokens) logger.info( - f"Generated embedding: {len(embedding)} dims, " - f"{len(tokens)} tokens, {latency_ms:.0f}ms" + f"Truncated text from {len(enc.encode(text))} to {len(tokens)} tokens" ) - return embedding + else: + truncated_text = text - except Exception as e: - logger.error(f"Failed to generate embedding: {e}") - return None + start_time = time.time() + response = await client.embeddings.create( + model=EMBEDDING_MODEL, + input=truncated_text, + ) + latency_ms = (time.time() - start_time) * 1000 + + embedding = response.data[0].embedding + logger.info( + f"Generated embedding: {len(embedding)} dims, " + f"{len(tokens)} tokens, {latency_ms:.0f}ms" + ) + return embedding async def store_embedding( @@ -144,48 +137,45 @@ async def store_content_embedding( New function for unified content embedding storage. Uses raw SQL since Prisma doesn't natively support pgvector. + + Raises exceptions on failure - caller should handle. """ - try: - client = tx if tx else prisma.get_client() + client = tx if tx else prisma.get_client() - # Convert embedding to PostgreSQL vector format - embedding_str = embedding_to_vector_string(embedding) - metadata_json = dumps(metadata or {}) + # Convert embedding to PostgreSQL vector format + embedding_str = embedding_to_vector_string(embedding) + metadata_json = dumps(metadata or {}) - # Upsert the embedding - # WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT - # Use unqualified ::vector - pgvector is in search_path on all environments - await execute_raw_with_schema( - """ - INSERT INTO {schema_prefix}"UnifiedContentEmbedding" ( - "id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt" - ) - VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::vector, $5, $6::jsonb, NOW(), NOW()) - ON CONFLICT ("contentType", "contentId", "userId") - DO UPDATE SET - "embedding" = $4::vector, - "searchableText" = $5, - "metadata" = $6::jsonb, - "updatedAt" = NOW() - WHERE {schema_prefix}"UnifiedContentEmbedding"."contentType" = $1::{schema_prefix}"ContentType" - AND {schema_prefix}"UnifiedContentEmbedding"."contentId" = $2 - AND ({schema_prefix}"UnifiedContentEmbedding"."userId" = $3 OR ($3 IS NULL AND {schema_prefix}"UnifiedContentEmbedding"."userId" IS NULL)) - """, - content_type, - content_id, - user_id, - embedding_str, - searchable_text, - metadata_json, - client=client, + # Upsert the embedding + # WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT + # Use unqualified ::vector - pgvector is in search_path on all environments + await execute_raw_with_schema( + """ + INSERT INTO {schema_prefix}"UnifiedContentEmbedding" ( + "id", "contentType", "contentId", "userId", "embedding", "searchableText", "metadata", "createdAt", "updatedAt" ) + VALUES (gen_random_uuid()::text, $1::{schema_prefix}"ContentType", $2, $3, $4::vector, $5, $6::jsonb, NOW(), NOW()) + ON CONFLICT ("contentType", "contentId", "userId") + DO UPDATE SET + "embedding" = $4::vector, + "searchableText" = $5, + "metadata" = $6::jsonb, + "updatedAt" = NOW() + WHERE {schema_prefix}"UnifiedContentEmbedding"."contentType" = $1::{schema_prefix}"ContentType" + AND {schema_prefix}"UnifiedContentEmbedding"."contentId" = $2 + AND ({schema_prefix}"UnifiedContentEmbedding"."userId" = $3 OR ($3 IS NULL AND {schema_prefix}"UnifiedContentEmbedding"."userId" IS NULL)) + """, + content_type, + content_id, + user_id, + embedding_str, + searchable_text, + metadata_json, + client=client, + ) - logger.info(f"Stored embedding for {content_type}:{content_id}") - return True - - except Exception as e: - logger.error(f"Failed to store embedding for {content_type}:{content_id}: {e}") - return False + logger.info(f"Stored embedding for {content_type}:{content_id}") + return True async def get_embedding(version_id: str) -> dict[str, Any] | None: @@ -217,34 +207,31 @@ async def get_content_embedding( New function for unified content embedding retrieval. Returns dict with contentType, contentId, embedding, timestamps or None if not found. + + Raises exceptions on failure - caller should handle. """ - try: - result = await query_raw_with_schema( - """ - SELECT - "contentType", - "contentId", - "userId", - "embedding"::text as "embedding", - "searchableText", - "metadata", - "createdAt", - "updatedAt" - FROM {schema_prefix}"UnifiedContentEmbedding" - WHERE "contentType" = $1::{schema_prefix}"ContentType" AND "contentId" = $2 AND ("userId" = $3 OR ($3 IS NULL AND "userId" IS NULL)) - """, - content_type, - content_id, - user_id, - ) + result = await query_raw_with_schema( + """ + SELECT + "contentType", + "contentId", + "userId", + "embedding"::text as "embedding", + "searchableText", + "metadata", + "createdAt", + "updatedAt" + FROM {schema_prefix}"UnifiedContentEmbedding" + WHERE "contentType" = $1::{schema_prefix}"ContentType" AND "contentId" = $2 AND ("userId" = $3 OR ($3 IS NULL AND "userId" IS NULL)) + """, + content_type, + content_id, + user_id, + ) - if result and len(result) > 0: - return result[0] - return None - - except Exception as e: - logger.error(f"Failed to get embedding for {content_type}:{content_id}: {e}") - return None + if result and len(result) > 0: + return result[0] + return None async def ensure_embedding( @@ -272,46 +259,38 @@ async def ensure_embedding( tx: Optional transaction client Returns: - True if embedding exists/was created, False on failure + True if embedding exists/was created + + Raises exceptions on failure - caller should handle. """ - try: - # Check if embedding already exists - if not force: - existing = await get_embedding(version_id) - if existing and existing.get("embedding"): - logger.debug(f"Embedding for version {version_id} already exists") - return True + # Check if embedding already exists + if not force: + existing = await get_embedding(version_id) + if existing and existing.get("embedding"): + logger.debug(f"Embedding for version {version_id} already exists") + return True - # Build searchable text for embedding - searchable_text = build_searchable_text( - name, description, sub_heading, categories - ) + # Build searchable text for embedding + searchable_text = build_searchable_text(name, description, sub_heading, categories) - # Generate new embedding - embedding = await generate_embedding(searchable_text) - if embedding is None: - logger.warning(f"Could not generate embedding for version {version_id}") - return False + # Generate new embedding + embedding = await generate_embedding(searchable_text) - # Store the embedding with metadata using new function - metadata = { - "name": name, - "subHeading": sub_heading, - "categories": categories, - } - return await store_content_embedding( - content_type=ContentType.STORE_AGENT, - content_id=version_id, - embedding=embedding, - searchable_text=searchable_text, - metadata=metadata, - user_id=None, # Store agents are public - tx=tx, - ) - - except Exception as e: - logger.error(f"Failed to ensure embedding for version {version_id}: {e}") - return False + # Store the embedding with metadata using new function + metadata = { + "name": name, + "subHeading": sub_heading, + "categories": categories, + } + return await store_content_embedding( + content_type=ContentType.STORE_AGENT, + content_id=version_id, + embedding=embedding, + searchable_text=searchable_text, + metadata=metadata, + user_id=None, # Store agents are public + tx=tx, + ) async def delete_embedding(version_id: str) -> bool: @@ -521,6 +500,24 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]: success = sum(1 for result in results if result is True) failed = len(results) - success + # Aggregate unique errors to avoid Sentry spam + if failed > 0: + # Group errors by type and message + error_summary: dict[str, int] = {} + for result in results: + if isinstance(result, Exception): + error_key = f"{type(result).__name__}: {str(result)}" + error_summary[error_key] = error_summary.get(error_key, 0) + 1 + + # Log aggregated error summary + error_details = ", ".join( + f"{error} ({count}x)" for error, count in error_summary.items() + ) + logger.error( + f"{content_type.value}: {failed}/{len(results)} embeddings failed. " + f"Errors: {error_details}" + ) + results_by_type[content_type.value] = { "processed": len(missing_items), "success": success, @@ -557,11 +554,12 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]: } -async def embed_query(query: str) -> list[float] | None: +async def embed_query(query: str) -> list[float]: """ Generate embedding for a search query. Same as generate_embedding but with clearer intent. + Raises exceptions on failure - caller should handle. """ return await generate_embedding(query) @@ -594,40 +592,30 @@ async def ensure_content_embedding( tx: Optional transaction client Returns: - True if embedding exists/was created, False on failure + True if embedding exists/was created + + Raises exceptions on failure - caller should handle. """ - try: - # Check if embedding already exists - if not force: - existing = await get_content_embedding(content_type, content_id, user_id) - if existing and existing.get("embedding"): - logger.debug( - f"Embedding for {content_type}:{content_id} already exists" - ) - return True + # Check if embedding already exists + if not force: + existing = await get_content_embedding(content_type, content_id, user_id) + if existing and existing.get("embedding"): + logger.debug(f"Embedding for {content_type}:{content_id} already exists") + return True - # Generate new embedding - embedding = await generate_embedding(searchable_text) - if embedding is None: - logger.warning( - f"Could not generate embedding for {content_type}:{content_id}" - ) - return False + # Generate new embedding + embedding = await generate_embedding(searchable_text) - # Store the embedding - return await store_content_embedding( - content_type=content_type, - content_id=content_id, - embedding=embedding, - searchable_text=searchable_text, - metadata=metadata or {}, - user_id=user_id, - tx=tx, - ) - - except Exception as e: - logger.error(f"Failed to ensure embedding for {content_type}:{content_id}: {e}") - return False + # Store the embedding + return await store_content_embedding( + content_type=content_type, + content_id=content_id, + embedding=embedding, + searchable_text=searchable_text, + metadata=metadata or {}, + user_id=user_id, + tx=tx, + ) async def cleanup_orphaned_embeddings() -> dict[str, Any]: @@ -854,9 +842,8 @@ async def semantic_search( limit = 100 # Generate query embedding - query_embedding = await embed_query(query) - - if query_embedding is not None: + try: + query_embedding = await embed_query(query) # Semantic search with embeddings embedding_str = embedding_to_vector_string(query_embedding) @@ -907,24 +894,21 @@ async def semantic_search( """ ) - try: - results = await query_raw_with_schema(sql, *params) - return [ - { - "content_id": row["content_id"], - "content_type": row["content_type"], - "searchable_text": row["searchable_text"], - "metadata": row["metadata"], - "similarity": float(row["similarity"]), - } - for row in results - ] - except Exception as e: - logger.error(f"Semantic search failed: {e}") - # Fall through to lexical search below + results = await query_raw_with_schema(sql, *params) + return [ + { + "content_id": row["content_id"], + "content_type": row["content_type"], + "searchable_text": row["searchable_text"], + "metadata": row["metadata"], + "similarity": float(row["similarity"]), + } + for row in results + ] + except Exception as e: + logger.warning(f"Semantic search failed, falling back to lexical search: {e}") # Fallback to lexical search if embeddings unavailable - logger.warning("Falling back to lexical search (embeddings unavailable)") params_lexical: list[Any] = [limit] user_filter = "" diff --git a/autogpt_platform/backend/backend/api/features/store/embeddings_schema_test.py b/autogpt_platform/backend/backend/api/features/store/embeddings_schema_test.py index 7ba200fda0..5aa13b4d23 100644 --- a/autogpt_platform/backend/backend/api/features/store/embeddings_schema_test.py +++ b/autogpt_platform/backend/backend/api/features/store/embeddings_schema_test.py @@ -298,17 +298,16 @@ async def test_schema_handling_error_cases(): mock_client.execute_raw.side_effect = Exception("Database error") mock_get_client.return_value = mock_client - result = await embeddings.store_content_embedding( - content_type=ContentType.STORE_AGENT, - content_id="test-id", - embedding=[0.1] * EMBEDDING_DIM, - searchable_text="test", - metadata=None, - user_id=None, - ) - - # Should return False on error, not raise - assert result is False + # Should raise exception on error + with pytest.raises(Exception, match="Database error"): + await embeddings.store_content_embedding( + content_type=ContentType.STORE_AGENT, + content_id="test-id", + embedding=[0.1] * EMBEDDING_DIM, + searchable_text="test", + metadata=None, + user_id=None, + ) if __name__ == "__main__": diff --git a/autogpt_platform/backend/backend/api/features/store/embeddings_test.py b/autogpt_platform/backend/backend/api/features/store/embeddings_test.py index 8cb471379b..0d5e5ce4a2 100644 --- a/autogpt_platform/backend/backend/api/features/store/embeddings_test.py +++ b/autogpt_platform/backend/backend/api/features/store/embeddings_test.py @@ -80,9 +80,8 @@ async def test_generate_embedding_no_api_key(): ) as mock_get_client: mock_get_client.return_value = None - result = await embeddings.generate_embedding("test text") - - assert result is None + with pytest.raises(RuntimeError, match="openai_internal_api_key not set"): + await embeddings.generate_embedding("test text") @pytest.mark.asyncio(loop_scope="session") @@ -97,9 +96,8 @@ async def test_generate_embedding_api_error(): ) as mock_get_client: mock_get_client.return_value = mock_client - result = await embeddings.generate_embedding("test text") - - assert result is None + with pytest.raises(Exception, match="API Error"): + await embeddings.generate_embedding("test text") @pytest.mark.asyncio(loop_scope="session") @@ -173,11 +171,10 @@ async def test_store_embedding_database_error(mocker): embedding = [0.1, 0.2, 0.3] - result = await embeddings.store_embedding( - version_id="test-version-id", embedding=embedding, tx=mock_client - ) - - assert result is False + with pytest.raises(Exception, match="Database error"): + await embeddings.store_embedding( + version_id="test-version-id", embedding=embedding, tx=mock_client + ) @pytest.mark.asyncio(loop_scope="session") @@ -277,17 +274,16 @@ async def test_ensure_embedding_create_new(mock_get, mock_store, mock_generate): async def test_ensure_embedding_generation_fails(mock_get, mock_generate): """Test ensure_embedding when generation fails.""" mock_get.return_value = None - mock_generate.return_value = None + mock_generate.side_effect = Exception("Generation failed") - result = await embeddings.ensure_embedding( - version_id="test-id", - name="Test", - description="Test description", - sub_heading="Test heading", - categories=["test"], - ) - - assert result is False + with pytest.raises(Exception, match="Generation failed"): + await embeddings.ensure_embedding( + version_id="test-id", + name="Test", + description="Test description", + sub_heading="Test heading", + categories=["test"], + ) @pytest.mark.asyncio(loop_scope="session") diff --git a/autogpt_platform/backend/backend/api/features/store/hybrid_search.py b/autogpt_platform/backend/backend/api/features/store/hybrid_search.py index 95ec3f4ff9..8b0884bb24 100644 --- a/autogpt_platform/backend/backend/api/features/store/hybrid_search.py +++ b/autogpt_platform/backend/backend/api/features/store/hybrid_search.py @@ -186,13 +186,12 @@ async def unified_hybrid_search( offset = (page - 1) * page_size - # Generate query embedding - query_embedding = await embed_query(query) - - # Graceful degradation if embedding unavailable - if query_embedding is None or not query_embedding: + # Generate query embedding with graceful degradation + try: + query_embedding = await embed_query(query) + except Exception as e: logger.warning( - "Failed to generate query embedding - falling back to lexical-only search. " + f"Failed to generate query embedding - falling back to lexical-only search: {e}. " "Check that openai_internal_api_key is configured and OpenAI API is accessible." ) query_embedding = [0.0] * EMBEDDING_DIM @@ -464,13 +463,12 @@ async def hybrid_search( offset = (page - 1) * page_size - # Generate query embedding - query_embedding = await embed_query(query) - - # Graceful degradation - if query_embedding is None or not query_embedding: + # Generate query embedding with graceful degradation + try: + query_embedding = await embed_query(query) + except Exception as e: logger.warning( - "Failed to generate query embedding - falling back to lexical-only search." + f"Failed to generate query embedding - falling back to lexical-only search: {e}" ) query_embedding = [0.0] * EMBEDDING_DIM total_non_semantic = ( diff --git a/autogpt_platform/backend/backend/api/features/store/hybrid_search_test.py b/autogpt_platform/backend/backend/api/features/store/hybrid_search_test.py index 7f942927a5..58989fbb41 100644 --- a/autogpt_platform/backend/backend/api/features/store/hybrid_search_test.py +++ b/autogpt_platform/backend/backend/api/features/store/hybrid_search_test.py @@ -172,8 +172,8 @@ async def test_hybrid_search_without_embeddings(): with patch( "backend.api.features.store.hybrid_search.query_raw_with_schema" ) as mock_query: - # Simulate embedding failure - mock_embed.return_value = None + # Simulate embedding failure by raising exception + mock_embed.side_effect = Exception("Embedding generation failed") mock_query.return_value = mock_results # Should NOT raise - graceful degradation @@ -613,7 +613,9 @@ async def test_unified_hybrid_search_graceful_degradation(): "backend.api.features.store.hybrid_search.embed_query" ) as mock_embed: mock_query.return_value = mock_results - mock_embed.return_value = None # Embedding failure + mock_embed.side_effect = Exception( + "Embedding generation failed" + ) # Embedding failure # Should NOT raise - graceful degradation results, total = await unified_hybrid_search( From fb58827c616903ce32c3102d01d39bb973e9ecd7 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Sat, 24 Jan 2026 16:05:25 -0500 Subject: [PATCH 06/36] feat(backend;frontend): Implement node-specific auto-approval, safety popup, and race condition fixes (#11810) ## Summary This PR implements comprehensive improvements to the human-in-the-loop (HITL) review system, including safety features, architectural changes, and bug fixes: ### Key Features - **SECRT-1798: One-time safety popup** - Shows informational popup before first run of AI-generated agents with sensitive actions/HITL blocks - **SECRT-1795: Auto-approval toggle UX** - Toggle in pending reviews panel to auto-approve future actions from the same node - **Node-specific auto-approval** - Changed from execution-specific to node-specific using special key pattern `auto_approve_{graph_exec_id}_{node_id}` - **Consolidated approval checking** - Merged `check_auto_approval` into `check_approval` using single OR query for better performance - **Race condition prevention** - Added execution status check before resuming to prevent duplicate execution when approving while graph is running - **Parallel auto-approval creation** - Uses `asyncio.gather` for better performance when creating multiple auto-approval records ## Changes ### Backend Architecture - **`human_review.py`**: - Added `check_approval()` function that checks both normal and auto-approval in single query - Added `create_auto_approval_record()` for node-specific auto-approval using special key pattern - Added `get_auto_approve_key()` helper to generate consistent auto-approval keys - **`review/routes.py`**: - Added execution status check before resuming to prevent race conditions - Refactored auto-approval record creation to use parallel execution with `asyncio.gather` - Removed obvious comments for cleaner code - **`review/model.py`**: Added `auto_approve_future_actions` field to `ReviewRequest` - **`blocks/helpers/review.py`**: Updated to use consolidated `check_approval` via database manager client - **`executor/database.py`**: Exposed `check_approval` through DatabaseManager RPC for block execution context - **`data/block.py`**: Fixed safe mode checks for sensitive action blocks ### Frontend - **New `AIAgentSafetyPopup`** component with localStorage-based one-time display - **`PendingReviewsList`**: - Replaced "Approve all future actions" button with toggle - Toggle resets data to original values and disables editing when enabled - Shows warning message explaining auto-approval behavior - **`RunAgentModal`**: Integrated safety popup before first run - **`usePendingReviews`**: Added polling for real-time badge updates - **`FloatingSafeModeToggle` & `SafeModeToggle`**: Simplified visibility logic - **`local-storage.ts`**: Added localStorage key for popup state tracking ### Bug Fixes - Fixed "Client is not connected to query engine" error by using database manager client pattern - Fixed race condition where approving reviews while graph is RUNNING could queue execution twice - Fixed migration to only drop FK constraint, not non-existent column - Fixed card data reset when auto-approve toggle changes ### Code Quality - Removed duplicate/obvious comments - Moved imports to top-level instead of local scope in tests - Used walrus operator for cleaner conditional assignments - Parallel execution for auto-approval record creation ## Test plan - [ ] Create an AI-generated agent with sensitive actions (e.g., email sending) - [ ] First run should show the safety popup before starting - [ ] Subsequent runs should not show the popup - [ ] Clear localStorage (`AI_AGENT_SAFETY_POPUP_SHOWN`) to verify popup shows again - [ ] Create an agent with human-in-the-loop blocks - [ ] Run it and verify the pending reviews panel appears - [ ] Enable the "Auto-approve all future actions" toggle - [ ] Verify editing is disabled and shows warning message - [ ] Click "Approve" and verify subsequent blocks from same node auto-approve - [ ] Verify auto-approval persists across multiple executions of same graph - [ ] Disable toggle and verify editing works normally - [ ] Verify "Reject" button still works regardless of toggle state - [ ] Test race condition: Approve reviews while graph is RUNNING (should skip resume) - [ ] Test race condition: Approve reviews while graph is REVIEW (should resume) - [ ] Verify pending reviews badge updates in real-time when new reviews are created --- .../api/features/chat/tools/run_agent_test.py | 22 +- .../api/features/executions/review/model.py | 24 +- .../executions/review/review_routes_test.py | 841 ++++++++++++++++-- .../api/features/executions/review/routes.py | 193 +++- .../backend/api/features/library/db.py | 8 +- .../backend/api/features/oauth_test.py | 13 +- .../backend/api/features/store/embeddings.py | 1 - .../backend/backend/blocks/basic.py | 1 + .../backend/backend/blocks/helpers/review.py | 55 +- .../backend/blocks/human_in_the_loop.py | 5 +- autogpt_platform/backend/backend/conftest.py | 6 +- .../backend/backend/data/block.py | 6 +- .../backend/backend/data/human_review.py | 295 +++++- .../backend/backend/data/human_review_test.py | 65 +- .../backend/backend/executor/database.py | 6 + .../backend/backend/executor/utils.py | 51 +- .../backend/backend/executor/utils_test.py | 232 +++++ autogpt_platform/backend/backend/util/test.py | 6 + .../migration.sql | 7 + autogpt_platform/backend/schema.prisma | 4 +- .../components/FloatingSafeModeToogle.tsx | 12 +- .../modals/RunAgentModal/RunAgentModal.tsx | 40 +- .../AIAgentSafetyPopup/AIAgentSafetyPopup.tsx | 108 +++ .../components/SafeModeToggle.tsx | 14 +- .../frontend/src/app/api/openapi.json | 16 +- .../FloatingReviewsPanel.tsx | 64 +- .../PendingReviewCard/PendingReviewCard.tsx | 174 ++-- .../PendingReviewsList/PendingReviewsList.tsx | 187 +++- .../frontend/src/hooks/usePendingReviews.ts | 18 +- .../src/services/storage/local-storage.ts | 1 + 30 files changed, 2156 insertions(+), 319 deletions(-) create mode 100644 autogpt_platform/backend/migrations/20260121200000_remove_node_execution_fk_from_pending_human_review/migration.sql create mode 100644 autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/AIAgentSafetyPopup/AIAgentSafetyPopup.tsx diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/run_agent_test.py b/autogpt_platform/backend/backend/api/features/chat/tools/run_agent_test.py index 9e10304429..404df2adb6 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/run_agent_test.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/run_agent_test.py @@ -29,7 +29,7 @@ def mock_embedding_functions(): yield -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent(setup_test_data): """Test that the run_agent tool successfully executes an approved agent""" # Use test data from fixture @@ -70,7 +70,7 @@ async def test_run_agent(setup_test_data): assert result_data["graph_name"] == "Test Agent" -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_missing_inputs(setup_test_data): """Test that the run_agent tool returns error when inputs are missing""" # Use test data from fixture @@ -106,7 +106,7 @@ async def test_run_agent_missing_inputs(setup_test_data): assert "message" in result_data -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_invalid_agent_id(setup_test_data): """Test that the run_agent tool returns error for invalid agent ID""" # Use test data from fixture @@ -141,7 +141,7 @@ async def test_run_agent_invalid_agent_id(setup_test_data): ) -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_with_llm_credentials(setup_llm_test_data): """Test that run_agent works with an agent requiring LLM credentials""" # Use test data from fixture @@ -185,7 +185,7 @@ async def test_run_agent_with_llm_credentials(setup_llm_test_data): assert result_data["graph_name"] == "LLM Test Agent" -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_shows_available_inputs_when_none_provided(setup_test_data): """Test that run_agent returns available inputs when called without inputs or use_defaults.""" user = setup_test_data["user"] @@ -219,7 +219,7 @@ async def test_run_agent_shows_available_inputs_when_none_provided(setup_test_da assert "inputs" in result_data["message"].lower() -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_with_use_defaults(setup_test_data): """Test that run_agent executes successfully with use_defaults=True.""" user = setup_test_data["user"] @@ -251,7 +251,7 @@ async def test_run_agent_with_use_defaults(setup_test_data): assert result_data["graph_id"] == graph.id -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_missing_credentials(setup_firecrawl_test_data): """Test that run_agent returns setup_requirements when credentials are missing.""" user = setup_firecrawl_test_data["user"] @@ -285,7 +285,7 @@ async def test_run_agent_missing_credentials(setup_firecrawl_test_data): assert len(setup_info["user_readiness"]["missing_credentials"]) > 0 -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_invalid_slug_format(setup_test_data): """Test that run_agent returns error for invalid slug format (no slash).""" user = setup_test_data["user"] @@ -313,7 +313,7 @@ async def test_run_agent_invalid_slug_format(setup_test_data): assert "username/agent-name" in result_data["message"] -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_unauthenticated(): """Test that run_agent returns need_login for unauthenticated users.""" tool = RunAgentTool() @@ -340,7 +340,7 @@ async def test_run_agent_unauthenticated(): assert "sign in" in result_data["message"].lower() -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_schedule_without_cron(setup_test_data): """Test that run_agent returns error when scheduling without cron expression.""" user = setup_test_data["user"] @@ -372,7 +372,7 @@ async def test_run_agent_schedule_without_cron(setup_test_data): assert "cron" in result_data["message"].lower() -@pytest.mark.asyncio(scope="session") +@pytest.mark.asyncio(loop_scope="session") async def test_run_agent_schedule_without_name(setup_test_data): """Test that run_agent returns error when scheduling without schedule_name.""" user = setup_test_data["user"] diff --git a/autogpt_platform/backend/backend/api/features/executions/review/model.py b/autogpt_platform/backend/backend/api/features/executions/review/model.py index 74f72fe1ff..bad8b8d304 100644 --- a/autogpt_platform/backend/backend/api/features/executions/review/model.py +++ b/autogpt_platform/backend/backend/api/features/executions/review/model.py @@ -23,6 +23,7 @@ class PendingHumanReviewModel(BaseModel): id: Unique identifier for the review record user_id: ID of the user who must perform the review node_exec_id: ID of the node execution that created this review + node_id: ID of the node definition (for grouping reviews from same node) graph_exec_id: ID of the graph execution containing the node graph_id: ID of the graph template being executed graph_version: Version number of the graph template @@ -37,6 +38,10 @@ class PendingHumanReviewModel(BaseModel): """ node_exec_id: str = Field(description="Node execution ID (primary key)") + node_id: str = Field( + description="Node definition ID (for grouping)", + default="", # Temporary default for test compatibility + ) user_id: str = Field(description="User ID associated with the review") graph_exec_id: str = Field(description="Graph execution ID") graph_id: str = Field(description="Graph ID") @@ -66,7 +71,9 @@ class PendingHumanReviewModel(BaseModel): ) @classmethod - def from_db(cls, review: "PendingHumanReview") -> "PendingHumanReviewModel": + def from_db( + cls, review: "PendingHumanReview", node_id: str + ) -> "PendingHumanReviewModel": """ Convert a database model to a response model. @@ -74,9 +81,14 @@ class PendingHumanReviewModel(BaseModel): payload, instructions, and editable flag. Handles invalid data gracefully by using safe defaults. + + Args: + review: Database review object + node_id: Node definition ID (fetched from NodeExecution) """ return cls( node_exec_id=review.nodeExecId, + node_id=node_id, user_id=review.userId, graph_exec_id=review.graphExecId, graph_id=review.graphId, @@ -107,6 +119,13 @@ class ReviewItem(BaseModel): reviewed_data: SafeJsonData | None = Field( None, description="Optional edited data (ignored if approved=False)" ) + auto_approve_future: bool = Field( + default=False, + description=( + "If true and this review is approved, future executions of this same " + "block (node) will be automatically approved. This only affects approved reviews." + ), + ) @field_validator("reviewed_data") @classmethod @@ -174,6 +193,9 @@ class ReviewRequest(BaseModel): This request must include ALL pending reviews for a graph execution. Each review will be either approved (with optional data modifications) or rejected (data ignored). The execution will resume only after ALL reviews are processed. + + Each review item can individually specify whether to auto-approve future executions + of the same block via the `auto_approve_future` field on ReviewItem. """ reviews: List[ReviewItem] = Field( diff --git a/autogpt_platform/backend/backend/api/features/executions/review/review_routes_test.py b/autogpt_platform/backend/backend/api/features/executions/review/review_routes_test.py index c4eba0befc..d0c24f2cf8 100644 --- a/autogpt_platform/backend/backend/api/features/executions/review/review_routes_test.py +++ b/autogpt_platform/backend/backend/api/features/executions/review/review_routes_test.py @@ -1,35 +1,43 @@ import datetime +from typing import AsyncGenerator -import fastapi -import fastapi.testclient +import httpx import pytest +import pytest_asyncio import pytest_mock from prisma.enums import ReviewStatus from pytest_snapshot.plugin import Snapshot -from backend.api.rest_api import handle_internal_http_error +from backend.api.rest_api import app +from backend.data.execution import ( + ExecutionContext, + ExecutionStatus, + NodeExecutionResult, +) +from backend.data.graph import GraphSettings from .model import PendingHumanReviewModel -from .routes import router # Using a fixed timestamp for reproducible tests FIXED_NOW = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) -app = fastapi.FastAPI() -app.include_router(router, prefix="/api/review") -app.add_exception_handler(ValueError, handle_internal_http_error(400)) -client = fastapi.testclient.TestClient(app) - - -@pytest.fixture(autouse=True) -def setup_app_auth(mock_jwt_user): - """Setup auth overrides for all tests in this module""" +@pytest_asyncio.fixture(loop_scope="session") +async def client(server, mock_jwt_user) -> AsyncGenerator[httpx.AsyncClient, None]: + """Create async HTTP client with auth overrides""" from autogpt_libs.auth.jwt_utils import get_jwt_payload + # Override get_jwt_payload dependency to return our test user app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"] - yield - app.dependency_overrides.clear() + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as http_client: + yield http_client + + # Clean up overrides + app.dependency_overrides.pop(get_jwt_payload, None) @pytest.fixture @@ -37,6 +45,7 @@ def sample_pending_review(test_user_id: str) -> PendingHumanReviewModel: """Create a sample pending review for testing""" return PendingHumanReviewModel( node_exec_id="test_node_123", + node_id="test_node_def_456", user_id=test_user_id, graph_exec_id="test_graph_exec_456", graph_id="test_graph_789", @@ -54,7 +63,9 @@ def sample_pending_review(test_user_id: str) -> PendingHumanReviewModel: ) -def test_get_pending_reviews_empty( +@pytest.mark.asyncio(loop_scope="session") +async def test_get_pending_reviews_empty( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, snapshot: Snapshot, test_user_id: str, @@ -65,14 +76,16 @@ def test_get_pending_reviews_empty( ) mock_get_reviews.return_value = [] - response = client.get("/api/review/pending") + response = await client.get("/api/review/pending") assert response.status_code == 200 assert response.json() == [] mock_get_reviews.assert_called_once_with(test_user_id, 1, 25) -def test_get_pending_reviews_with_data( +@pytest.mark.asyncio(loop_scope="session") +async def test_get_pending_reviews_with_data( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, sample_pending_review: PendingHumanReviewModel, snapshot: Snapshot, @@ -84,7 +97,7 @@ def test_get_pending_reviews_with_data( ) mock_get_reviews.return_value = [sample_pending_review] - response = client.get("/api/review/pending?page=2&page_size=10") + response = await client.get("/api/review/pending?page=2&page_size=10") assert response.status_code == 200 data = response.json() @@ -94,7 +107,9 @@ def test_get_pending_reviews_with_data( mock_get_reviews.assert_called_once_with(test_user_id, 2, 10) -def test_get_pending_reviews_for_execution_success( +@pytest.mark.asyncio(loop_scope="session") +async def test_get_pending_reviews_for_execution_success( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, sample_pending_review: PendingHumanReviewModel, snapshot: Snapshot, @@ -114,7 +129,7 @@ def test_get_pending_reviews_for_execution_success( ) mock_get_reviews.return_value = [sample_pending_review] - response = client.get("/api/review/execution/test_graph_exec_456") + response = await client.get("/api/review/execution/test_graph_exec_456") assert response.status_code == 200 data = response.json() @@ -122,7 +137,9 @@ def test_get_pending_reviews_for_execution_success( assert data[0]["graph_exec_id"] == "test_graph_exec_456" -def test_get_pending_reviews_for_execution_not_available( +@pytest.mark.asyncio(loop_scope="session") +async def test_get_pending_reviews_for_execution_not_available( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, ) -> None: """Test access denied when user doesn't own the execution""" @@ -131,13 +148,15 @@ def test_get_pending_reviews_for_execution_not_available( ) mock_get_graph_execution.return_value = None - response = client.get("/api/review/execution/test_graph_exec_456") + response = await client.get("/api/review/execution/test_graph_exec_456") assert response.status_code == 404 assert "not found" in response.json()["detail"] -def test_process_review_action_approve_success( +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_approve_success( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, sample_pending_review: PendingHumanReviewModel, test_user_id: str, @@ -145,6 +164,12 @@ def test_process_review_action_approve_success( """Test successful review approval""" # Mock the route functions + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} + mock_get_reviews_for_execution = mocker.patch( "backend.api.features.executions.review.routes.get_pending_reviews_for_execution" ) @@ -173,6 +198,14 @@ def test_process_review_action_approve_success( ) mock_process_all_reviews.return_value = {"test_node_123": approved_review} + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + mock_has_pending = mocker.patch( "backend.api.features.executions.review.routes.has_pending_reviews_for_graph_exec" ) @@ -191,7 +224,7 @@ def test_process_review_action_approve_success( ] } - response = client.post("/api/review/action", json=request_data) + response = await client.post("/api/review/action", json=request_data) assert response.status_code == 200 data = response.json() @@ -201,7 +234,9 @@ def test_process_review_action_approve_success( assert data["error"] is None -def test_process_review_action_reject_success( +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_reject_success( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, sample_pending_review: PendingHumanReviewModel, test_user_id: str, @@ -209,6 +244,20 @@ def test_process_review_action_reject_success( """Test successful review rejection""" # Mock the route functions + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} + + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + mock_get_reviews_for_execution = mocker.patch( "backend.api.features.executions.review.routes.get_pending_reviews_for_execution" ) @@ -251,7 +300,7 @@ def test_process_review_action_reject_success( ] } - response = client.post("/api/review/action", json=request_data) + response = await client.post("/api/review/action", json=request_data) assert response.status_code == 200 data = response.json() @@ -261,7 +310,9 @@ def test_process_review_action_reject_success( assert data["error"] is None -def test_process_review_action_mixed_success( +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_mixed_success( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, sample_pending_review: PendingHumanReviewModel, test_user_id: str, @@ -288,6 +339,15 @@ def test_process_review_action_mixed_success( # Mock the route functions + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + mock_get_reviews_for_user.return_value = { + "test_node_123": sample_pending_review, + "test_node_456": second_review, + } + mock_get_reviews_for_execution = mocker.patch( "backend.api.features.executions.review.routes.get_pending_reviews_for_execution" ) @@ -337,6 +397,14 @@ def test_process_review_action_mixed_success( "test_node_456": rejected_review, } + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + mock_has_pending = mocker.patch( "backend.api.features.executions.review.routes.has_pending_reviews_for_graph_exec" ) @@ -358,7 +426,7 @@ def test_process_review_action_mixed_success( ] } - response = client.post("/api/review/action", json=request_data) + response = await client.post("/api/review/action", json=request_data) assert response.status_code == 200 data = response.json() @@ -368,14 +436,16 @@ def test_process_review_action_mixed_success( assert data["error"] is None -def test_process_review_action_empty_request( +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_empty_request( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, test_user_id: str, ) -> None: """Test error when no reviews provided""" request_data = {"reviews": []} - response = client.post("/api/review/action", json=request_data) + response = await client.post("/api/review/action", json=request_data) assert response.status_code == 422 response_data = response.json() @@ -385,11 +455,29 @@ def test_process_review_action_empty_request( assert "At least one review must be provided" in response_data["detail"][0]["msg"] -def test_process_review_action_review_not_found( +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_review_not_found( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, + sample_pending_review: PendingHumanReviewModel, test_user_id: str, ) -> None: """Test error when review is not found""" + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + # Return empty dict to simulate review not found + mock_get_reviews_for_user.return_value = {} + + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + # Mock the functions that extract graph execution ID from the request mock_get_reviews_for_execution = mocker.patch( "backend.api.features.executions.review.routes.get_pending_reviews_for_execution" @@ -415,18 +503,34 @@ def test_process_review_action_review_not_found( ] } - response = client.post("/api/review/action", json=request_data) + response = await client.post("/api/review/action", json=request_data) - assert response.status_code == 400 - assert "Reviews not found" in response.json()["detail"] + assert response.status_code == 404 + assert "No pending review found" in response.json()["detail"] -def test_process_review_action_partial_failure( +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_partial_failure( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, sample_pending_review: PendingHumanReviewModel, test_user_id: str, ) -> None: """Test handling of partial failures in review processing""" + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} + + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + # Mock the route functions mock_get_reviews_for_execution = mocker.patch( "backend.api.features.executions.review.routes.get_pending_reviews_for_execution" @@ -449,31 +553,34 @@ def test_process_review_action_partial_failure( ] } - response = client.post("/api/review/action", json=request_data) + response = await client.post("/api/review/action", json=request_data) assert response.status_code == 400 assert "Some reviews failed validation" in response.json()["detail"] -def test_process_review_action_invalid_node_exec_id( +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_invalid_node_exec_id( + client: httpx.AsyncClient, mocker: pytest_mock.MockerFixture, sample_pending_review: PendingHumanReviewModel, test_user_id: str, ) -> None: """Test failure when trying to process review with invalid node execution ID""" - # Mock the route functions - mock_get_reviews_for_execution = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_for_execution" + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" ) - mock_get_reviews_for_execution.return_value = [sample_pending_review] + # Return empty dict to simulate review not found + mock_get_reviews_for_user.return_value = {} - # Mock validation failure - this should return 400, not 500 - mock_process_all_reviews = mocker.patch( - "backend.api.features.executions.review.routes.process_all_reviews_for_execution" - ) - mock_process_all_reviews.side_effect = ValueError( - "Invalid node execution ID format" + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta request_data = { "reviews": [ @@ -485,8 +592,638 @@ def test_process_review_action_invalid_node_exec_id( ] } - response = client.post("/api/review/action", json=request_data) + response = await client.post("/api/review/action", json=request_data) - # Should be a 400 Bad Request, not 500 Internal Server Error - assert response.status_code == 400 - assert "Invalid node execution ID format" in response.json()["detail"] + # Returns 404 when review is not found + assert response.status_code == 404 + assert "No pending review found" in response.json()["detail"] + + +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_auto_approve_creates_auto_approval_records( + client: httpx.AsyncClient, + mocker: pytest_mock.MockerFixture, + sample_pending_review: PendingHumanReviewModel, + test_user_id: str, +) -> None: + """Test that auto_approve_future_actions flag creates auto-approval records""" + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} + + # Mock process_all_reviews + mock_process_all_reviews = mocker.patch( + "backend.api.features.executions.review.routes.process_all_reviews_for_execution" + ) + approved_review = PendingHumanReviewModel( + node_exec_id="test_node_123", + user_id=test_user_id, + graph_exec_id="test_graph_exec_456", + graph_id="test_graph_789", + graph_version=1, + payload={"data": "test payload"}, + instructions="Please review", + editable=True, + status=ReviewStatus.APPROVED, + review_message="Approved", + was_edited=False, + processed=False, + created_at=FIXED_NOW, + updated_at=FIXED_NOW, + reviewed_at=FIXED_NOW, + ) + mock_process_all_reviews.return_value = {"test_node_123": approved_review} + + # Mock get_node_executions to return node_id mapping + mock_get_node_executions = mocker.patch( + "backend.data.execution.get_node_executions" + ) + mock_node_exec = mocker.Mock(spec=NodeExecutionResult) + mock_node_exec.node_exec_id = "test_node_123" + mock_node_exec.node_id = "test_node_def_456" + mock_get_node_executions.return_value = [mock_node_exec] + + # Mock create_auto_approval_record + mock_create_auto_approval = mocker.patch( + "backend.api.features.executions.review.routes.create_auto_approval_record" + ) + + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + + # Mock has_pending_reviews_for_graph_exec + mock_has_pending = mocker.patch( + "backend.api.features.executions.review.routes.has_pending_reviews_for_graph_exec" + ) + mock_has_pending.return_value = False + + # Mock get_graph_settings to return custom settings + mock_get_settings = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_settings" + ) + mock_get_settings.return_value = GraphSettings( + human_in_the_loop_safe_mode=True, + sensitive_action_safe_mode=True, + ) + + # Mock get_user_by_id to prevent database access + mock_get_user = mocker.patch( + "backend.api.features.executions.review.routes.get_user_by_id" + ) + mock_user = mocker.Mock() + mock_user.timezone = "UTC" + mock_get_user.return_value = mock_user + + # Mock add_graph_execution + mock_add_execution = mocker.patch( + "backend.api.features.executions.review.routes.add_graph_execution" + ) + + request_data = { + "reviews": [ + { + "node_exec_id": "test_node_123", + "approved": True, + "message": "Approved", + "auto_approve_future": True, + } + ], + } + + response = await client.post("/api/review/action", json=request_data) + + assert response.status_code == 200 + + # Verify process_all_reviews_for_execution was called (without auto_approve param) + mock_process_all_reviews.assert_called_once() + + # Verify create_auto_approval_record was called for the approved review + mock_create_auto_approval.assert_called_once_with( + user_id=test_user_id, + graph_exec_id="test_graph_exec_456", + graph_id="test_graph_789", + graph_version=1, + node_id="test_node_def_456", + payload={"data": "test payload"}, + ) + + # Verify get_graph_settings was called with correct parameters + mock_get_settings.assert_called_once_with( + user_id=test_user_id, graph_id="test_graph_789" + ) + + # Verify add_graph_execution was called with proper ExecutionContext + mock_add_execution.assert_called_once() + call_kwargs = mock_add_execution.call_args.kwargs + execution_context = call_kwargs["execution_context"] + + assert isinstance(execution_context, ExecutionContext) + assert execution_context.human_in_the_loop_safe_mode is True + assert execution_context.sensitive_action_safe_mode is True + + +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_without_auto_approve_still_loads_settings( + client: httpx.AsyncClient, + mocker: pytest_mock.MockerFixture, + sample_pending_review: PendingHumanReviewModel, + test_user_id: str, +) -> None: + """Test that execution context is created with settings even without auto-approve""" + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} + + # Mock process_all_reviews + mock_process_all_reviews = mocker.patch( + "backend.api.features.executions.review.routes.process_all_reviews_for_execution" + ) + approved_review = PendingHumanReviewModel( + node_exec_id="test_node_123", + user_id=test_user_id, + graph_exec_id="test_graph_exec_456", + graph_id="test_graph_789", + graph_version=1, + payload={"data": "test payload"}, + instructions="Please review", + editable=True, + status=ReviewStatus.APPROVED, + review_message="Approved", + was_edited=False, + processed=False, + created_at=FIXED_NOW, + updated_at=FIXED_NOW, + reviewed_at=FIXED_NOW, + ) + mock_process_all_reviews.return_value = {"test_node_123": approved_review} + + # Mock create_auto_approval_record - should NOT be called when auto_approve is False + mock_create_auto_approval = mocker.patch( + "backend.api.features.executions.review.routes.create_auto_approval_record" + ) + + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + + # Mock has_pending_reviews_for_graph_exec + mock_has_pending = mocker.patch( + "backend.api.features.executions.review.routes.has_pending_reviews_for_graph_exec" + ) + mock_has_pending.return_value = False + + # Mock get_graph_settings with sensitive_action_safe_mode enabled + mock_get_settings = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_settings" + ) + mock_get_settings.return_value = GraphSettings( + human_in_the_loop_safe_mode=False, + sensitive_action_safe_mode=True, + ) + + # Mock get_user_by_id to prevent database access + mock_get_user = mocker.patch( + "backend.api.features.executions.review.routes.get_user_by_id" + ) + mock_user = mocker.Mock() + mock_user.timezone = "UTC" + mock_get_user.return_value = mock_user + + # Mock add_graph_execution + mock_add_execution = mocker.patch( + "backend.api.features.executions.review.routes.add_graph_execution" + ) + + # Request WITHOUT auto_approve_future (defaults to False) + request_data = { + "reviews": [ + { + "node_exec_id": "test_node_123", + "approved": True, + "message": "Approved", + # auto_approve_future defaults to False + } + ], + } + + response = await client.post("/api/review/action", json=request_data) + + assert response.status_code == 200 + + # Verify process_all_reviews_for_execution was called + mock_process_all_reviews.assert_called_once() + + # Verify create_auto_approval_record was NOT called (auto_approve_future=False) + mock_create_auto_approval.assert_not_called() + + # Verify settings were loaded + mock_get_settings.assert_called_once() + + # Verify ExecutionContext has proper settings + mock_add_execution.assert_called_once() + call_kwargs = mock_add_execution.call_args.kwargs + execution_context = call_kwargs["execution_context"] + + assert isinstance(execution_context, ExecutionContext) + assert execution_context.human_in_the_loop_safe_mode is False + assert execution_context.sensitive_action_safe_mode is True + + +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_auto_approve_only_applies_to_approved_reviews( + client: httpx.AsyncClient, + mocker: pytest_mock.MockerFixture, + test_user_id: str, +) -> None: + """Test that auto_approve record is created only for approved reviews""" + # Create two reviews - one approved, one rejected + approved_review = PendingHumanReviewModel( + node_exec_id="node_exec_approved", + user_id=test_user_id, + graph_exec_id="test_graph_exec_456", + graph_id="test_graph_789", + graph_version=1, + payload={"data": "approved"}, + instructions="Review", + editable=True, + status=ReviewStatus.APPROVED, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + updated_at=FIXED_NOW, + reviewed_at=FIXED_NOW, + ) + rejected_review = PendingHumanReviewModel( + node_exec_id="node_exec_rejected", + user_id=test_user_id, + graph_exec_id="test_graph_exec_456", + graph_id="test_graph_789", + graph_version=1, + payload={"data": "rejected"}, + instructions="Review", + editable=True, + status=ReviewStatus.REJECTED, + review_message="Rejected", + was_edited=False, + processed=False, + created_at=FIXED_NOW, + updated_at=FIXED_NOW, + reviewed_at=FIXED_NOW, + ) + + # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + # Need to return both reviews in WAITING state (before processing) + approved_review_waiting = PendingHumanReviewModel( + node_exec_id="node_exec_approved", + user_id=test_user_id, + graph_exec_id="test_graph_exec_456", + graph_id="test_graph_789", + graph_version=1, + payload={"data": "approved"}, + instructions="Review", + editable=True, + status=ReviewStatus.WAITING, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + ) + rejected_review_waiting = PendingHumanReviewModel( + node_exec_id="node_exec_rejected", + user_id=test_user_id, + graph_exec_id="test_graph_exec_456", + graph_id="test_graph_789", + graph_version=1, + payload={"data": "rejected"}, + instructions="Review", + editable=True, + status=ReviewStatus.WAITING, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + ) + mock_get_reviews_for_user.return_value = { + "node_exec_approved": approved_review_waiting, + "node_exec_rejected": rejected_review_waiting, + } + + # Mock process_all_reviews + mock_process_all_reviews = mocker.patch( + "backend.api.features.executions.review.routes.process_all_reviews_for_execution" + ) + mock_process_all_reviews.return_value = { + "node_exec_approved": approved_review, + "node_exec_rejected": rejected_review, + } + + # Mock get_node_executions to return node_id mapping + mock_get_node_executions = mocker.patch( + "backend.data.execution.get_node_executions" + ) + mock_node_exec = mocker.Mock(spec=NodeExecutionResult) + mock_node_exec.node_exec_id = "node_exec_approved" + mock_node_exec.node_id = "test_node_def_approved" + mock_get_node_executions.return_value = [mock_node_exec] + + # Mock create_auto_approval_record + mock_create_auto_approval = mocker.patch( + "backend.api.features.executions.review.routes.create_auto_approval_record" + ) + + # Mock get_graph_execution_meta to return execution in REVIEW status + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + + # Mock has_pending_reviews_for_graph_exec + mock_has_pending = mocker.patch( + "backend.api.features.executions.review.routes.has_pending_reviews_for_graph_exec" + ) + mock_has_pending.return_value = False + + # Mock get_graph_settings + mock_get_settings = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_settings" + ) + mock_get_settings.return_value = GraphSettings() + + # Mock get_user_by_id to prevent database access + mock_get_user = mocker.patch( + "backend.api.features.executions.review.routes.get_user_by_id" + ) + mock_user = mocker.Mock() + mock_user.timezone = "UTC" + mock_get_user.return_value = mock_user + + # Mock add_graph_execution + mock_add_execution = mocker.patch( + "backend.api.features.executions.review.routes.add_graph_execution" + ) + + request_data = { + "reviews": [ + { + "node_exec_id": "node_exec_approved", + "approved": True, + "auto_approve_future": True, + }, + { + "node_exec_id": "node_exec_rejected", + "approved": False, + "auto_approve_future": True, # Should be ignored since rejected + }, + ], + } + + response = await client.post("/api/review/action", json=request_data) + + assert response.status_code == 200 + + # Verify process_all_reviews_for_execution was called + mock_process_all_reviews.assert_called_once() + + # Verify create_auto_approval_record was called ONLY for the approved review + # (not for the rejected one) + mock_create_auto_approval.assert_called_once_with( + user_id=test_user_id, + graph_exec_id="test_graph_exec_456", + graph_id="test_graph_789", + graph_version=1, + node_id="test_node_def_approved", + payload={"data": "approved"}, + ) + + # Verify get_node_executions was called to batch-fetch node data + mock_get_node_executions.assert_called_once() + + # Verify ExecutionContext was created (auto-approval is now DB-based) + call_kwargs = mock_add_execution.call_args.kwargs + execution_context = call_kwargs["execution_context"] + assert isinstance(execution_context, ExecutionContext) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_process_review_action_per_review_auto_approve_granularity( + client: httpx.AsyncClient, + mocker: pytest_mock.MockerFixture, + sample_pending_review: PendingHumanReviewModel, + test_user_id: str, +) -> None: + """Test that auto-approval can be set per-review (granular control)""" + # Mock get_pending_reviews_by_node_exec_ids - return different reviews based on node_exec_id + mock_get_reviews_for_user = mocker.patch( + "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + ) + + # Create a mapping of node_exec_id to review + review_map = { + "node_1_auto": PendingHumanReviewModel( + node_exec_id="node_1_auto", + user_id=test_user_id, + graph_exec_id="test_graph_exec", + graph_id="test_graph", + graph_version=1, + payload={"data": "node1"}, + instructions="Review 1", + editable=True, + status=ReviewStatus.WAITING, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + ), + "node_2_manual": PendingHumanReviewModel( + node_exec_id="node_2_manual", + user_id=test_user_id, + graph_exec_id="test_graph_exec", + graph_id="test_graph", + graph_version=1, + payload={"data": "node2"}, + instructions="Review 2", + editable=True, + status=ReviewStatus.WAITING, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + ), + "node_3_auto": PendingHumanReviewModel( + node_exec_id="node_3_auto", + user_id=test_user_id, + graph_exec_id="test_graph_exec", + graph_id="test_graph", + graph_version=1, + payload={"data": "node3"}, + instructions="Review 3", + editable=True, + status=ReviewStatus.WAITING, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + ), + } + + # Return the review map dict (batch function returns all requested reviews) + mock_get_reviews_for_user.return_value = review_map + + # Mock process_all_reviews - return 3 approved reviews + mock_process_all_reviews = mocker.patch( + "backend.api.features.executions.review.routes.process_all_reviews_for_execution" + ) + mock_process_all_reviews.return_value = { + "node_1_auto": PendingHumanReviewModel( + node_exec_id="node_1_auto", + user_id=test_user_id, + graph_exec_id="test_graph_exec", + graph_id="test_graph", + graph_version=1, + payload={"data": "node1"}, + instructions="Review 1", + editable=True, + status=ReviewStatus.APPROVED, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + updated_at=FIXED_NOW, + reviewed_at=FIXED_NOW, + ), + "node_2_manual": PendingHumanReviewModel( + node_exec_id="node_2_manual", + user_id=test_user_id, + graph_exec_id="test_graph_exec", + graph_id="test_graph", + graph_version=1, + payload={"data": "node2"}, + instructions="Review 2", + editable=True, + status=ReviewStatus.APPROVED, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + updated_at=FIXED_NOW, + reviewed_at=FIXED_NOW, + ), + "node_3_auto": PendingHumanReviewModel( + node_exec_id="node_3_auto", + user_id=test_user_id, + graph_exec_id="test_graph_exec", + graph_id="test_graph", + graph_version=1, + payload={"data": "node3"}, + instructions="Review 3", + editable=True, + status=ReviewStatus.APPROVED, + review_message=None, + was_edited=False, + processed=False, + created_at=FIXED_NOW, + updated_at=FIXED_NOW, + reviewed_at=FIXED_NOW, + ), + } + + # Mock get_node_executions to return batch node data + mock_get_node_executions = mocker.patch( + "backend.data.execution.get_node_executions" + ) + # Create mock node executions for each review + mock_node_execs = [] + for node_exec_id in ["node_1_auto", "node_2_manual", "node_3_auto"]: + mock_node = mocker.Mock(spec=NodeExecutionResult) + mock_node.node_exec_id = node_exec_id + mock_node.node_id = f"node_def_{node_exec_id}" + mock_node_execs.append(mock_node) + mock_get_node_executions.return_value = mock_node_execs + + # Mock create_auto_approval_record + mock_create_auto_approval = mocker.patch( + "backend.api.features.executions.review.routes.create_auto_approval_record" + ) + + # Mock get_graph_execution_meta + mock_get_graph_exec = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_execution_meta" + ) + mock_graph_exec_meta = mocker.Mock() + mock_graph_exec_meta.status = ExecutionStatus.REVIEW + mock_get_graph_exec.return_value = mock_graph_exec_meta + + # Mock has_pending_reviews_for_graph_exec + mock_has_pending = mocker.patch( + "backend.api.features.executions.review.routes.has_pending_reviews_for_graph_exec" + ) + mock_has_pending.return_value = False + + # Mock settings and execution + mock_get_settings = mocker.patch( + "backend.api.features.executions.review.routes.get_graph_settings" + ) + mock_get_settings.return_value = GraphSettings( + human_in_the_loop_safe_mode=False, sensitive_action_safe_mode=False + ) + + mocker.patch("backend.api.features.executions.review.routes.add_graph_execution") + mocker.patch("backend.api.features.executions.review.routes.get_user_by_id") + + # Request with granular auto-approval: + # - node_1_auto: auto_approve_future=True + # - node_2_manual: auto_approve_future=False (explicit) + # - node_3_auto: auto_approve_future=True + request_data = { + "reviews": [ + { + "node_exec_id": "node_1_auto", + "approved": True, + "auto_approve_future": True, + }, + { + "node_exec_id": "node_2_manual", + "approved": True, + "auto_approve_future": False, # Don't auto-approve this one + }, + { + "node_exec_id": "node_3_auto", + "approved": True, + "auto_approve_future": True, + }, + ], + } + + response = await client.post("/api/review/action", json=request_data) + + assert response.status_code == 200 + + # Verify create_auto_approval_record was called ONLY for reviews with auto_approve_future=True + assert mock_create_auto_approval.call_count == 2 + + # Check that it was called for node_1 and node_3, but NOT node_2 + call_args_list = [call.kwargs for call in mock_create_auto_approval.call_args_list] + node_ids_with_auto_approval = [args["node_id"] for args in call_args_list] + + assert "node_def_node_1_auto" in node_ids_with_auto_approval + assert "node_def_node_3_auto" in node_ids_with_auto_approval + assert "node_def_node_2_manual" not in node_ids_with_auto_approval diff --git a/autogpt_platform/backend/backend/api/features/executions/review/routes.py b/autogpt_platform/backend/backend/api/features/executions/review/routes.py index 88646046da..a10071e9cb 100644 --- a/autogpt_platform/backend/backend/api/features/executions/review/routes.py +++ b/autogpt_platform/backend/backend/api/features/executions/review/routes.py @@ -1,17 +1,27 @@ +import asyncio import logging -from typing import List +from typing import Any, List import autogpt_libs.auth as autogpt_auth_lib from fastapi import APIRouter, HTTPException, Query, Security, status from prisma.enums import ReviewStatus -from backend.data.execution import get_graph_execution_meta +from backend.data.execution import ( + ExecutionContext, + ExecutionStatus, + get_graph_execution_meta, +) +from backend.data.graph import get_graph_settings from backend.data.human_review import ( + create_auto_approval_record, + get_pending_reviews_by_node_exec_ids, get_pending_reviews_for_execution, get_pending_reviews_for_user, has_pending_reviews_for_graph_exec, process_all_reviews_for_execution, ) +from backend.data.model import USER_TIMEZONE_NOT_SET +from backend.data.user import get_user_by_id from backend.executor.utils import add_graph_execution from .model import PendingHumanReviewModel, ReviewRequest, ReviewResponse @@ -127,17 +137,70 @@ async def process_review_action( detail="At least one review must be provided", ) - # Build review decisions map + # Batch fetch all requested reviews + reviews_map = await get_pending_reviews_by_node_exec_ids( + list(all_request_node_ids), user_id + ) + + # Validate all reviews were found + missing_ids = all_request_node_ids - set(reviews_map.keys()) + if missing_ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No pending review found for node execution(s): {', '.join(missing_ids)}", + ) + + # Validate all reviews belong to the same execution + graph_exec_ids = {review.graph_exec_id for review in reviews_map.values()} + if len(graph_exec_ids) > 1: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="All reviews in a single request must belong to the same execution.", + ) + + graph_exec_id = next(iter(graph_exec_ids)) + + # Validate execution status before processing reviews + graph_exec_meta = await get_graph_execution_meta( + user_id=user_id, execution_id=graph_exec_id + ) + + if not graph_exec_meta: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Graph execution #{graph_exec_id} not found", + ) + + # Only allow processing reviews if execution is paused for review + # or incomplete (partial execution with some reviews already processed) + if graph_exec_meta.status not in ( + ExecutionStatus.REVIEW, + ExecutionStatus.INCOMPLETE, + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Cannot process reviews while execution status is {graph_exec_meta.status}. " + f"Reviews can only be processed when execution is paused (REVIEW status). " + f"Current status: {graph_exec_meta.status}", + ) + + # Build review decisions map and track which reviews requested auto-approval + # Auto-approved reviews use original data (no modifications allowed) review_decisions = {} + auto_approve_requests = {} # Map node_exec_id -> auto_approve_future flag + for review in request.reviews: review_status = ( ReviewStatus.APPROVED if review.approved else ReviewStatus.REJECTED ) + # If this review requested auto-approval, don't allow data modifications + reviewed_data = None if review.auto_approve_future else review.reviewed_data review_decisions[review.node_exec_id] = ( review_status, - review.reviewed_data, + reviewed_data, review.message, ) + auto_approve_requests[review.node_exec_id] = review.auto_approve_future # Process all reviews updated_reviews = await process_all_reviews_for_execution( @@ -145,6 +208,87 @@ async def process_review_action( review_decisions=review_decisions, ) + # Create auto-approval records for approved reviews that requested it + # Deduplicate by node_id to avoid race conditions when multiple reviews + # for the same node are processed in parallel + async def create_auto_approval_for_node( + node_id: str, review_result + ) -> tuple[str, bool]: + """ + Create auto-approval record for a node. + Returns (node_id, success) tuple for tracking failures. + """ + try: + await create_auto_approval_record( + user_id=user_id, + graph_exec_id=review_result.graph_exec_id, + graph_id=review_result.graph_id, + graph_version=review_result.graph_version, + node_id=node_id, + payload=review_result.payload, + ) + return (node_id, True) + except Exception as e: + logger.error( + f"Failed to create auto-approval record for node {node_id}", + exc_info=e, + ) + return (node_id, False) + + # Collect node_exec_ids that need auto-approval + node_exec_ids_needing_auto_approval = [ + node_exec_id + for node_exec_id, review_result in updated_reviews.items() + if review_result.status == ReviewStatus.APPROVED + and auto_approve_requests.get(node_exec_id, False) + ] + + # Batch-fetch node executions to get node_ids + nodes_needing_auto_approval: dict[str, Any] = {} + if node_exec_ids_needing_auto_approval: + from backend.data.execution import get_node_executions + + node_execs = await get_node_executions( + graph_exec_id=graph_exec_id, include_exec_data=False + ) + node_exec_map = {node_exec.node_exec_id: node_exec for node_exec in node_execs} + + for node_exec_id in node_exec_ids_needing_auto_approval: + node_exec = node_exec_map.get(node_exec_id) + if node_exec: + review_result = updated_reviews[node_exec_id] + # Use the first approved review for this node (deduplicate by node_id) + if node_exec.node_id not in nodes_needing_auto_approval: + nodes_needing_auto_approval[node_exec.node_id] = review_result + else: + logger.error( + f"Failed to create auto-approval record for {node_exec_id}: " + f"Node execution not found. This may indicate a race condition " + f"or data inconsistency." + ) + + # Execute all auto-approval creations in parallel (deduplicated by node_id) + auto_approval_results = await asyncio.gather( + *[ + create_auto_approval_for_node(node_id, review_result) + for node_id, review_result in nodes_needing_auto_approval.items() + ], + return_exceptions=True, + ) + + # Count auto-approval failures + auto_approval_failed_count = 0 + for result in auto_approval_results: + if isinstance(result, Exception): + # Unexpected exception during auto-approval creation + auto_approval_failed_count += 1 + logger.error( + f"Unexpected exception during auto-approval creation: {result}" + ) + elif isinstance(result, tuple) and len(result) == 2 and not result[1]: + # Auto-approval creation failed (returned False) + auto_approval_failed_count += 1 + # Count results approved_count = sum( 1 @@ -157,30 +301,53 @@ async def process_review_action( if review.status == ReviewStatus.REJECTED ) - # Resume execution if we processed some reviews + # Resume execution only if ALL pending reviews for this execution have been processed if updated_reviews: - # Get graph execution ID from any processed review - first_review = next(iter(updated_reviews.values())) - graph_exec_id = first_review.graph_exec_id - - # Check if any pending reviews remain for this execution still_has_pending = await has_pending_reviews_for_graph_exec(graph_exec_id) if not still_has_pending: - # Resume execution + # Get the graph_id from any processed review + first_review = next(iter(updated_reviews.values())) + try: + # Fetch user and settings to build complete execution context + user = await get_user_by_id(user_id) + settings = await get_graph_settings( + user_id=user_id, graph_id=first_review.graph_id + ) + + # Preserve user's timezone preference when resuming execution + user_timezone = ( + user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC" + ) + + execution_context = ExecutionContext( + human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode, + sensitive_action_safe_mode=settings.sensitive_action_safe_mode, + user_timezone=user_timezone, + ) + await add_graph_execution( graph_id=first_review.graph_id, user_id=user_id, graph_exec_id=graph_exec_id, + execution_context=execution_context, ) logger.info(f"Resumed execution {graph_exec_id}") except Exception as e: logger.error(f"Failed to resume execution {graph_exec_id}: {str(e)}") + # Build error message if auto-approvals failed + error_message = None + if auto_approval_failed_count > 0: + error_message = ( + f"{auto_approval_failed_count} auto-approval setting(s) could not be saved. " + f"You may need to manually approve these reviews in future executions." + ) + return ReviewResponse( approved_count=approved_count, rejected_count=rejected_count, - failed_count=0, - error=None, + failed_count=auto_approval_failed_count, + error=error_message, ) diff --git a/autogpt_platform/backend/backend/api/features/library/db.py b/autogpt_platform/backend/backend/api/features/library/db.py index 0c775802db..18d535d896 100644 --- a/autogpt_platform/backend/backend/api/features/library/db.py +++ b/autogpt_platform/backend/backend/api/features/library/db.py @@ -583,7 +583,13 @@ async def update_library_agent( ) update_fields["isDeleted"] = is_deleted if settings is not None: - update_fields["settings"] = SafeJson(settings.model_dump()) + existing_agent = await get_library_agent(id=library_agent_id, user_id=user_id) + current_settings_dict = ( + existing_agent.settings.model_dump() if existing_agent.settings else {} + ) + new_settings = settings.model_dump(exclude_unset=True) + merged_settings = {**current_settings_dict, **new_settings} + update_fields["settings"] = SafeJson(merged_settings) try: # If graph_version is provided, update to that specific version diff --git a/autogpt_platform/backend/backend/api/features/oauth_test.py b/autogpt_platform/backend/backend/api/features/oauth_test.py index 5f6b85a88a..5fd35f82e7 100644 --- a/autogpt_platform/backend/backend/api/features/oauth_test.py +++ b/autogpt_platform/backend/backend/api/features/oauth_test.py @@ -20,6 +20,7 @@ from typing import AsyncGenerator import httpx import pytest +import pytest_asyncio from autogpt_libs.api_key.keysmith import APIKeySmith from prisma.enums import APIKeyPermission from prisma.models import OAuthAccessToken as PrismaOAuthAccessToken @@ -38,13 +39,13 @@ keysmith = APIKeySmith() # ============================================================================ -@pytest.fixture +@pytest.fixture(scope="session") def test_user_id() -> str: """Test user ID for OAuth tests.""" return str(uuid.uuid4()) -@pytest.fixture +@pytest_asyncio.fixture(scope="session", loop_scope="session") async def test_user(server, test_user_id: str): """Create a test user in the database.""" await PrismaUser.prisma().create( @@ -67,7 +68,7 @@ async def test_user(server, test_user_id: str): await PrismaUser.prisma().delete(where={"id": test_user_id}) -@pytest.fixture +@pytest_asyncio.fixture async def test_oauth_app(test_user: str): """Create a test OAuth application in the database.""" app_id = str(uuid.uuid4()) @@ -122,7 +123,7 @@ def pkce_credentials() -> tuple[str, str]: return generate_pkce() -@pytest.fixture +@pytest_asyncio.fixture async def client(server, test_user: str) -> AsyncGenerator[httpx.AsyncClient, None]: """ Create an async HTTP client that talks directly to the FastAPI app. @@ -287,7 +288,7 @@ async def test_authorize_invalid_client_returns_error( assert query_params["error"][0] == "invalid_client" -@pytest.fixture +@pytest_asyncio.fixture async def inactive_oauth_app(test_user: str): """Create an inactive test OAuth application in the database.""" app_id = str(uuid.uuid4()) @@ -1004,7 +1005,7 @@ async def test_token_refresh_revoked( assert "revoked" in response.json()["detail"].lower() -@pytest.fixture +@pytest_asyncio.fixture async def other_oauth_app(test_user: str): """Create a second OAuth application for cross-app tests.""" app_id = str(uuid.uuid4()) diff --git a/autogpt_platform/backend/backend/api/features/store/embeddings.py b/autogpt_platform/backend/backend/api/features/store/embeddings.py index 3aa2f3bdbb..79a9a4e219 100644 --- a/autogpt_platform/backend/backend/api/features/store/embeddings.py +++ b/autogpt_platform/backend/backend/api/features/store/embeddings.py @@ -21,7 +21,6 @@ from backend.util.json import dumps logger = logging.getLogger(__name__) - # OpenAI embedding model configuration EMBEDDING_MODEL = "text-embedding-3-small" # Embedding dimension for the model above diff --git a/autogpt_platform/backend/backend/blocks/basic.py b/autogpt_platform/backend/backend/blocks/basic.py index 4d452f3b34..a9c77e2b93 100644 --- a/autogpt_platform/backend/backend/blocks/basic.py +++ b/autogpt_platform/backend/backend/blocks/basic.py @@ -116,6 +116,7 @@ class PrintToConsoleBlock(Block): input_schema=PrintToConsoleBlock.Input, output_schema=PrintToConsoleBlock.Output, test_input={"text": "Hello, World!"}, + is_sensitive_action=True, test_output=[ ("output", "Hello, World!"), ("status", "printed"), diff --git a/autogpt_platform/backend/backend/blocks/helpers/review.py b/autogpt_platform/backend/backend/blocks/helpers/review.py index 80c28cfd14..4bd85e424b 100644 --- a/autogpt_platform/backend/backend/blocks/helpers/review.py +++ b/autogpt_platform/backend/backend/blocks/helpers/review.py @@ -9,7 +9,7 @@ from typing import Any, Optional from prisma.enums import ReviewStatus from pydantic import BaseModel -from backend.data.execution import ExecutionContext, ExecutionStatus +from backend.data.execution import ExecutionStatus from backend.data.human_review import ReviewResult from backend.executor.manager import async_update_node_execution_status from backend.util.clients import get_database_manager_async_client @@ -28,6 +28,11 @@ class ReviewDecision(BaseModel): class HITLReviewHelper: """Helper class for Human-In-The-Loop review operations.""" + @staticmethod + async def check_approval(**kwargs) -> Optional[ReviewResult]: + """Check if there's an existing approval for this node execution.""" + return await get_database_manager_async_client().check_approval(**kwargs) + @staticmethod async def get_or_create_human_review(**kwargs) -> Optional[ReviewResult]: """Create or retrieve a human review from the database.""" @@ -55,11 +60,11 @@ class HITLReviewHelper: async def _handle_review_request( input_data: Any, user_id: str, + node_id: str, node_exec_id: str, graph_exec_id: str, graph_id: str, graph_version: int, - execution_context: ExecutionContext, block_name: str = "Block", editable: bool = False, ) -> Optional[ReviewResult]: @@ -69,11 +74,11 @@ class HITLReviewHelper: Args: input_data: The input data to be reviewed user_id: ID of the user requesting the review + node_id: ID of the node in the graph definition node_exec_id: ID of the node execution graph_exec_id: ID of the graph execution graph_id: ID of the graph graph_version: Version of the graph - execution_context: Current execution context block_name: Name of the block requesting review editable: Whether the reviewer can edit the data @@ -83,15 +88,41 @@ class HITLReviewHelper: Raises: Exception: If review creation or status update fails """ - # Skip review if safe mode is disabled - return auto-approved result - if not execution_context.human_in_the_loop_safe_mode: + # Note: Safe mode checks (human_in_the_loop_safe_mode, sensitive_action_safe_mode) + # are handled by the caller: + # - HITL blocks check human_in_the_loop_safe_mode in their run() method + # - Sensitive action blocks check sensitive_action_safe_mode in is_block_exec_need_review() + # This function only handles checking for existing approvals. + + # Check if this node has already been approved (normal or auto-approval) + if approval_result := await HITLReviewHelper.check_approval( + node_exec_id=node_exec_id, + graph_exec_id=graph_exec_id, + node_id=node_id, + user_id=user_id, + input_data=input_data, + ): logger.info( - f"Block {block_name} skipping review for node {node_exec_id} - safe mode disabled" + f"Block {block_name} skipping review for node {node_exec_id} - " + f"found existing approval" + ) + # Return a new ReviewResult with the current node_exec_id but approved status + # For auto-approvals, always use current input_data + # For normal approvals, use approval_result.data unless it's None + is_auto_approval = approval_result.node_exec_id != node_exec_id + approved_data = ( + input_data + if is_auto_approval + else ( + approval_result.data + if approval_result.data is not None + else input_data + ) ) return ReviewResult( - data=input_data, + data=approved_data, status=ReviewStatus.APPROVED, - message="Auto-approved (safe mode disabled)", + message=approval_result.message, processed=True, node_exec_id=node_exec_id, ) @@ -103,7 +134,7 @@ class HITLReviewHelper: graph_id=graph_id, graph_version=graph_version, input_data=input_data, - message=f"Review required for {block_name} execution", + message=block_name, # Use block_name directly as the message editable=editable, ) @@ -129,11 +160,11 @@ class HITLReviewHelper: async def handle_review_decision( input_data: Any, user_id: str, + node_id: str, node_exec_id: str, graph_exec_id: str, graph_id: str, graph_version: int, - execution_context: ExecutionContext, block_name: str = "Block", editable: bool = False, ) -> Optional[ReviewDecision]: @@ -143,11 +174,11 @@ class HITLReviewHelper: Args: input_data: The input data to be reviewed user_id: ID of the user requesting the review + node_id: ID of the node in the graph definition node_exec_id: ID of the node execution graph_exec_id: ID of the graph execution graph_id: ID of the graph graph_version: Version of the graph - execution_context: Current execution context block_name: Name of the block requesting review editable: Whether the reviewer can edit the data @@ -158,11 +189,11 @@ class HITLReviewHelper: review_result = await HITLReviewHelper._handle_review_request( input_data=input_data, user_id=user_id, + node_id=node_id, node_exec_id=node_exec_id, graph_exec_id=graph_exec_id, graph_id=graph_id, graph_version=graph_version, - execution_context=execution_context, block_name=block_name, editable=editable, ) diff --git a/autogpt_platform/backend/backend/blocks/human_in_the_loop.py b/autogpt_platform/backend/backend/blocks/human_in_the_loop.py index b6106843bd..568ac4b33f 100644 --- a/autogpt_platform/backend/backend/blocks/human_in_the_loop.py +++ b/autogpt_platform/backend/backend/blocks/human_in_the_loop.py @@ -97,6 +97,7 @@ class HumanInTheLoopBlock(Block): input_data: Input, *, user_id: str, + node_id: str, node_exec_id: str, graph_exec_id: str, graph_id: str, @@ -115,12 +116,12 @@ class HumanInTheLoopBlock(Block): decision = await self.handle_review_decision( input_data=input_data.data, user_id=user_id, + node_id=node_id, node_exec_id=node_exec_id, graph_exec_id=graph_exec_id, graph_id=graph_id, graph_version=graph_version, - execution_context=execution_context, - block_name=self.name, + block_name=input_data.name, # Use user-provided name instead of block type editable=input_data.editable, ) diff --git a/autogpt_platform/backend/backend/conftest.py b/autogpt_platform/backend/backend/conftest.py index b0b7f0cc67..57481e4b85 100644 --- a/autogpt_platform/backend/backend/conftest.py +++ b/autogpt_platform/backend/backend/conftest.py @@ -1,7 +1,7 @@ import logging import os -import pytest +import pytest_asyncio from dotenv import load_dotenv from backend.util.logging import configure_logging @@ -19,7 +19,7 @@ if not os.getenv("PRISMA_DEBUG"): prisma_logger.setLevel(logging.INFO) -@pytest.fixture(scope="session") +@pytest_asyncio.fixture(scope="session", loop_scope="session") async def server(): from backend.util.test import SpinTestServer @@ -27,7 +27,7 @@ async def server(): yield server -@pytest.fixture(scope="session", autouse=True) +@pytest_asyncio.fixture(scope="session", loop_scope="session", autouse=True) async def graph_cleanup(server): created_graph_ids = [] original_create_graph = server.agent_server.test_create_graph diff --git a/autogpt_platform/backend/backend/data/block.py b/autogpt_platform/backend/backend/data/block.py index 4bfa3892e2..8d9ecfff4c 100644 --- a/autogpt_platform/backend/backend/data/block.py +++ b/autogpt_platform/backend/backend/data/block.py @@ -441,6 +441,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]): static_output: bool = False, block_type: BlockType = BlockType.STANDARD, webhook_config: Optional[BlockWebhookConfig | BlockManualWebhookConfig] = None, + is_sensitive_action: bool = False, ): """ Initialize the block with the given schema. @@ -473,8 +474,8 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]): self.static_output = static_output self.block_type = block_type self.webhook_config = webhook_config + self.is_sensitive_action = is_sensitive_action self.execution_stats: NodeExecutionStats = NodeExecutionStats() - self.is_sensitive_action: bool = False if self.webhook_config: if isinstance(self.webhook_config, BlockWebhookConfig): @@ -622,6 +623,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]): input_data: BlockInput, *, user_id: str, + node_id: str, node_exec_id: str, graph_exec_id: str, graph_id: str, @@ -648,11 +650,11 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]): decision = await HITLReviewHelper.handle_review_decision( input_data=input_data, user_id=user_id, + node_id=node_id, node_exec_id=node_exec_id, graph_exec_id=graph_exec_id, graph_id=graph_id, graph_version=graph_version, - execution_context=execution_context, block_name=self.name, editable=True, ) diff --git a/autogpt_platform/backend/backend/data/human_review.py b/autogpt_platform/backend/backend/data/human_review.py index de7a30759e..c70eaa7b64 100644 --- a/autogpt_platform/backend/backend/data/human_review.py +++ b/autogpt_platform/backend/backend/data/human_review.py @@ -6,10 +6,10 @@ Handles all database operations for pending human reviews. import asyncio import logging from datetime import datetime, timezone -from typing import Optional +from typing import TYPE_CHECKING, Optional from prisma.enums import ReviewStatus -from prisma.models import PendingHumanReview +from prisma.models import AgentNodeExecution, PendingHumanReview from prisma.types import PendingHumanReviewUpdateInput from pydantic import BaseModel @@ -17,8 +17,12 @@ from backend.api.features.executions.review.model import ( PendingHumanReviewModel, SafeJsonData, ) +from backend.data.execution import get_graph_execution_meta from backend.util.json import SafeJson +if TYPE_CHECKING: + pass + logger = logging.getLogger(__name__) @@ -32,6 +36,125 @@ class ReviewResult(BaseModel): node_exec_id: str +def get_auto_approve_key(graph_exec_id: str, node_id: str) -> str: + """Generate the special nodeExecId key for auto-approval records.""" + return f"auto_approve_{graph_exec_id}_{node_id}" + + +async def check_approval( + node_exec_id: str, + graph_exec_id: str, + node_id: str, + user_id: str, + input_data: SafeJsonData | None = None, +) -> Optional[ReviewResult]: + """ + Check if there's an existing approval for this node execution. + + Checks both: + 1. Normal approval by node_exec_id (previous run of the same node execution) + 2. Auto-approval by special key pattern "auto_approve_{graph_exec_id}_{node_id}" + + Args: + node_exec_id: ID of the node execution + graph_exec_id: ID of the graph execution + node_id: ID of the node definition (not execution) + user_id: ID of the user (for data isolation) + input_data: Current input data (used for auto-approvals to avoid stale data) + + Returns: + ReviewResult if approval found (either normal or auto), None otherwise + """ + auto_approve_key = get_auto_approve_key(graph_exec_id, node_id) + + # Check for either normal approval or auto-approval in a single query + existing_review = await PendingHumanReview.prisma().find_first( + where={ + "OR": [ + {"nodeExecId": node_exec_id}, + {"nodeExecId": auto_approve_key}, + ], + "status": ReviewStatus.APPROVED, + "userId": user_id, + }, + ) + + if existing_review: + is_auto_approval = existing_review.nodeExecId == auto_approve_key + logger.info( + f"Found {'auto-' if is_auto_approval else ''}approval for node {node_id} " + f"(exec: {node_exec_id}) in execution {graph_exec_id}" + ) + # For auto-approvals, use current input_data to avoid replaying stale payload + # For normal approvals, use the stored payload (which may have been edited) + return ReviewResult( + data=( + input_data + if is_auto_approval and input_data is not None + else existing_review.payload + ), + status=ReviewStatus.APPROVED, + message=( + "Auto-approved (user approved all future actions for this node)" + if is_auto_approval + else existing_review.reviewMessage or "" + ), + processed=True, + node_exec_id=existing_review.nodeExecId, + ) + + return None + + +async def create_auto_approval_record( + user_id: str, + graph_exec_id: str, + graph_id: str, + graph_version: int, + node_id: str, + payload: SafeJsonData, +) -> None: + """ + Create an auto-approval record for a node in this execution. + + This is stored as a PendingHumanReview with a special nodeExecId pattern + and status=APPROVED, so future executions of the same node can skip review. + + Raises: + ValueError: If the graph execution doesn't belong to the user + """ + # Validate that the graph execution belongs to this user (defense in depth) + graph_exec = await get_graph_execution_meta( + user_id=user_id, execution_id=graph_exec_id + ) + if not graph_exec: + raise ValueError( + f"Graph execution {graph_exec_id} not found or doesn't belong to user {user_id}" + ) + + auto_approve_key = get_auto_approve_key(graph_exec_id, node_id) + + await PendingHumanReview.prisma().upsert( + where={"nodeExecId": auto_approve_key}, + data={ + "create": { + "nodeExecId": auto_approve_key, + "userId": user_id, + "graphExecId": graph_exec_id, + "graphId": graph_id, + "graphVersion": graph_version, + "payload": SafeJson(payload), + "instructions": "Auto-approval record", + "editable": False, + "status": ReviewStatus.APPROVED, + "processed": True, + "reviewedAt": datetime.now(timezone.utc), + }, + "update": {}, # Already exists, no update needed + }, + ) + + async def get_or_create_human_review( user_id: str, node_exec_id: str, @@ -108,6 +231,87 @@ async def get_or_create_human_review( ) +async def get_pending_review_by_node_exec_id( + node_exec_id: str, user_id: str +) -> Optional["PendingHumanReviewModel"]: + """ + Get a pending review by its node execution ID. + + Args: + node_exec_id: The node execution ID to look up + user_id: User ID for authorization (only returns if review belongs to this user) + + Returns: + The pending review if found and belongs to user, None otherwise + """ + review = await PendingHumanReview.prisma().find_first( + where={ + "nodeExecId": node_exec_id, + "userId": user_id, + "status": ReviewStatus.WAITING, + } + ) + + if not review: + return None + + # Local import to avoid event loop conflicts in tests + from backend.data.execution import get_node_execution + + node_exec = await get_node_execution(review.nodeExecId) + node_id = node_exec.node_id if node_exec else review.nodeExecId + return PendingHumanReviewModel.from_db(review, node_id=node_id) + + +async def get_pending_reviews_by_node_exec_ids( + node_exec_ids: list[str], user_id: str +) -> dict[str, "PendingHumanReviewModel"]: + """ + Get multiple pending reviews by their node execution IDs in a single batch query. + + Args: + node_exec_ids: List of node execution IDs to look up + user_id: User ID for authorization (only returns reviews belonging to this user) + + Returns: + Dictionary mapping node_exec_id -> PendingHumanReviewModel for found reviews + """ + if not node_exec_ids: + return {} + + reviews = await PendingHumanReview.prisma().find_many( + where={ + "nodeExecId": {"in": node_exec_ids}, + "userId": user_id, + "status": ReviewStatus.WAITING, + } + ) + + if not reviews: + return {} + + # Batch fetch all node executions to avoid N+1 queries + node_exec_ids_to_fetch = [review.nodeExecId for review in reviews] + node_execs = await AgentNodeExecution.prisma().find_many( + where={"id": {"in": node_exec_ids_to_fetch}}, + include={"Node": True}, + ) + + # Create mapping from node_exec_id to node_id + node_exec_id_to_node_id = { + node_exec.id: node_exec.agentNodeId for node_exec in node_execs + } + + result = {} + for review in reviews: + node_id = node_exec_id_to_node_id.get(review.nodeExecId, review.nodeExecId) + result[review.nodeExecId] = PendingHumanReviewModel.from_db( + review, node_id=node_id + ) + + return result + + async def has_pending_reviews_for_graph_exec(graph_exec_id: str) -> bool: """ Check if a graph execution has any pending reviews. @@ -137,8 +341,11 @@ async def get_pending_reviews_for_user( page_size: Number of reviews per page Returns: - List of pending review models + List of pending review models with node_id included """ + # Local import to avoid event loop conflicts in tests + from backend.data.execution import get_node_execution + # Calculate offset for pagination offset = (page - 1) * page_size @@ -149,7 +356,14 @@ async def get_pending_reviews_for_user( take=page_size, ) - return [PendingHumanReviewModel.from_db(review) for review in reviews] + # Fetch node_id for each review from NodeExecution + result = [] + for review in reviews: + node_exec = await get_node_execution(review.nodeExecId) + node_id = node_exec.node_id if node_exec else review.nodeExecId + result.append(PendingHumanReviewModel.from_db(review, node_id=node_id)) + + return result async def get_pending_reviews_for_execution( @@ -163,8 +377,11 @@ async def get_pending_reviews_for_execution( user_id: User ID for security validation Returns: - List of pending review models + List of pending review models with node_id included """ + # Local import to avoid event loop conflicts in tests + from backend.data.execution import get_node_execution + reviews = await PendingHumanReview.prisma().find_many( where={ "userId": user_id, @@ -174,7 +391,14 @@ async def get_pending_reviews_for_execution( order={"createdAt": "asc"}, ) - return [PendingHumanReviewModel.from_db(review) for review in reviews] + # Fetch node_id for each review from NodeExecution + result = [] + for review in reviews: + node_exec = await get_node_execution(review.nodeExecId) + node_id = node_exec.node_id if node_exec else review.nodeExecId + result.append(PendingHumanReviewModel.from_db(review, node_id=node_id)) + + return result async def process_all_reviews_for_execution( @@ -244,11 +468,19 @@ async def process_all_reviews_for_execution( # Note: Execution resumption is now handled at the API layer after ALL reviews # for an execution are processed (both approved and rejected) - # Return as dict for easy access - return { - review.nodeExecId: PendingHumanReviewModel.from_db(review) - for review in updated_reviews - } + # Fetch node_id for each review and return as dict for easy access + # Local import to avoid event loop conflicts in tests + from backend.data.execution import get_node_execution + + result = {} + for review in updated_reviews: + node_exec = await get_node_execution(review.nodeExecId) + node_id = node_exec.node_id if node_exec else review.nodeExecId + result[review.nodeExecId] = PendingHumanReviewModel.from_db( + review, node_id=node_id + ) + + return result async def update_review_processed_status(node_exec_id: str, processed: bool) -> None: @@ -256,3 +488,44 @@ async def update_review_processed_status(node_exec_id: str, processed: bool) -> await PendingHumanReview.prisma().update( where={"nodeExecId": node_exec_id}, data={"processed": processed} ) + + +async def cancel_pending_reviews_for_execution(graph_exec_id: str, user_id: str) -> int: + """ + Cancel all pending reviews for a graph execution (e.g., when execution is stopped). + + Marks all WAITING reviews as REJECTED with a message indicating the execution was stopped. + + Args: + graph_exec_id: The graph execution ID + user_id: User ID who owns the execution (for security validation) + + Returns: + Number of reviews cancelled + + Raises: + ValueError: If the graph execution doesn't belong to the user + """ + # Validate user ownership before cancelling reviews + graph_exec = await get_graph_execution_meta( + user_id=user_id, execution_id=graph_exec_id + ) + if not graph_exec: + raise ValueError( + f"Graph execution {graph_exec_id} not found or doesn't belong to user {user_id}" + ) + + result = await PendingHumanReview.prisma().update_many( + where={ + "graphExecId": graph_exec_id, + "userId": user_id, + "status": ReviewStatus.WAITING, + }, + data={ + "status": ReviewStatus.REJECTED, + "reviewMessage": "Execution was stopped by user", + "processed": True, + "reviewedAt": datetime.now(timezone.utc), + }, + ) + return result diff --git a/autogpt_platform/backend/backend/data/human_review_test.py b/autogpt_platform/backend/backend/data/human_review_test.py index c349fdde46..baa5c0c0c4 100644 --- a/autogpt_platform/backend/backend/data/human_review_test.py +++ b/autogpt_platform/backend/backend/data/human_review_test.py @@ -36,7 +36,7 @@ def sample_db_review(): return mock_review -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_get_or_create_human_review_new( mocker: pytest_mock.MockFixture, sample_db_review, @@ -46,8 +46,8 @@ async def test_get_or_create_human_review_new( sample_db_review.status = ReviewStatus.WAITING sample_db_review.processed = False - mock_upsert = mocker.patch("backend.data.human_review.PendingHumanReview.prisma") - mock_upsert.return_value.upsert = AsyncMock(return_value=sample_db_review) + mock_prisma = mocker.patch("backend.data.human_review.PendingHumanReview.prisma") + mock_prisma.return_value.upsert = AsyncMock(return_value=sample_db_review) result = await get_or_create_human_review( user_id="test-user-123", @@ -64,7 +64,7 @@ async def test_get_or_create_human_review_new( assert result is None -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_get_or_create_human_review_approved( mocker: pytest_mock.MockFixture, sample_db_review, @@ -75,8 +75,8 @@ async def test_get_or_create_human_review_approved( sample_db_review.processed = False sample_db_review.reviewMessage = "Looks good" - mock_upsert = mocker.patch("backend.data.human_review.PendingHumanReview.prisma") - mock_upsert.return_value.upsert = AsyncMock(return_value=sample_db_review) + mock_prisma = mocker.patch("backend.data.human_review.PendingHumanReview.prisma") + mock_prisma.return_value.upsert = AsyncMock(return_value=sample_db_review) result = await get_or_create_human_review( user_id="test-user-123", @@ -96,7 +96,7 @@ async def test_get_or_create_human_review_approved( assert result.message == "Looks good" -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_has_pending_reviews_for_graph_exec_true( mocker: pytest_mock.MockFixture, ): @@ -109,7 +109,7 @@ async def test_has_pending_reviews_for_graph_exec_true( assert result is True -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_has_pending_reviews_for_graph_exec_false( mocker: pytest_mock.MockFixture, ): @@ -122,7 +122,7 @@ async def test_has_pending_reviews_for_graph_exec_false( assert result is False -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_get_pending_reviews_for_user( mocker: pytest_mock.MockFixture, sample_db_review, @@ -131,10 +131,19 @@ async def test_get_pending_reviews_for_user( mock_find_many = mocker.patch("backend.data.human_review.PendingHumanReview.prisma") mock_find_many.return_value.find_many = AsyncMock(return_value=[sample_db_review]) + # Mock get_node_execution to return node with node_id (async function) + mock_node_exec = Mock() + mock_node_exec.node_id = "test_node_def_789" + mocker.patch( + "backend.data.execution.get_node_execution", + new=AsyncMock(return_value=mock_node_exec), + ) + result = await get_pending_reviews_for_user("test_user", page=2, page_size=10) assert len(result) == 1 assert result[0].node_exec_id == "test_node_123" + assert result[0].node_id == "test_node_def_789" # Verify pagination parameters call_args = mock_find_many.return_value.find_many.call_args @@ -142,7 +151,7 @@ async def test_get_pending_reviews_for_user( assert call_args.kwargs["take"] == 10 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_get_pending_reviews_for_execution( mocker: pytest_mock.MockFixture, sample_db_review, @@ -151,12 +160,21 @@ async def test_get_pending_reviews_for_execution( mock_find_many = mocker.patch("backend.data.human_review.PendingHumanReview.prisma") mock_find_many.return_value.find_many = AsyncMock(return_value=[sample_db_review]) + # Mock get_node_execution to return node with node_id (async function) + mock_node_exec = Mock() + mock_node_exec.node_id = "test_node_def_789" + mocker.patch( + "backend.data.execution.get_node_execution", + new=AsyncMock(return_value=mock_node_exec), + ) + result = await get_pending_reviews_for_execution( "test_graph_exec_456", "test-user-123" ) assert len(result) == 1 assert result[0].graph_exec_id == "test_graph_exec_456" + assert result[0].node_id == "test_node_def_789" # Verify it filters by execution and user call_args = mock_find_many.return_value.find_many.call_args @@ -166,7 +184,7 @@ async def test_get_pending_reviews_for_execution( assert where_clause["status"] == ReviewStatus.WAITING -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_process_all_reviews_for_execution_success( mocker: pytest_mock.MockFixture, sample_db_review, @@ -201,6 +219,14 @@ async def test_process_all_reviews_for_execution_success( new=AsyncMock(return_value=[updated_review]), ) + # Mock get_node_execution to return node with node_id (async function) + mock_node_exec = Mock() + mock_node_exec.node_id = "test_node_def_789" + mocker.patch( + "backend.data.execution.get_node_execution", + new=AsyncMock(return_value=mock_node_exec), + ) + result = await process_all_reviews_for_execution( user_id="test-user-123", review_decisions={ @@ -211,9 +237,10 @@ async def test_process_all_reviews_for_execution_success( assert len(result) == 1 assert "test_node_123" in result assert result["test_node_123"].status == ReviewStatus.APPROVED + assert result["test_node_123"].node_id == "test_node_def_789" -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_process_all_reviews_for_execution_validation_errors( mocker: pytest_mock.MockFixture, ): @@ -233,7 +260,7 @@ async def test_process_all_reviews_for_execution_validation_errors( ) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_process_all_reviews_edit_permission_error( mocker: pytest_mock.MockFixture, sample_db_review, @@ -259,7 +286,7 @@ async def test_process_all_reviews_edit_permission_error( ) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="function") async def test_process_all_reviews_mixed_approval_rejection( mocker: pytest_mock.MockFixture, sample_db_review, @@ -329,6 +356,14 @@ async def test_process_all_reviews_mixed_approval_rejection( new=AsyncMock(return_value=[approved_review, rejected_review]), ) + # Mock get_node_execution to return node with node_id (async function) + mock_node_exec = Mock() + mock_node_exec.node_id = "test_node_def_789" + mocker.patch( + "backend.data.execution.get_node_execution", + new=AsyncMock(return_value=mock_node_exec), + ) + result = await process_all_reviews_for_execution( user_id="test-user-123", review_decisions={ @@ -340,3 +375,5 @@ async def test_process_all_reviews_mixed_approval_rejection( assert len(result) == 2 assert "test_node_123" in result assert "test_node_456" in result + assert result["test_node_123"].node_id == "test_node_def_789" + assert result["test_node_456"].node_id == "test_node_def_789" diff --git a/autogpt_platform/backend/backend/executor/database.py b/autogpt_platform/backend/backend/executor/database.py index ac381bbd67..ae7474fc1d 100644 --- a/autogpt_platform/backend/backend/executor/database.py +++ b/autogpt_platform/backend/backend/executor/database.py @@ -50,6 +50,8 @@ from backend.data.graph import ( validate_graph_execution_permissions, ) from backend.data.human_review import ( + cancel_pending_reviews_for_execution, + check_approval, get_or_create_human_review, has_pending_reviews_for_graph_exec, update_review_processed_status, @@ -190,6 +192,8 @@ class DatabaseManager(AppService): get_user_notification_preference = _(get_user_notification_preference) # Human In The Loop + cancel_pending_reviews_for_execution = _(cancel_pending_reviews_for_execution) + check_approval = _(check_approval) get_or_create_human_review = _(get_or_create_human_review) has_pending_reviews_for_graph_exec = _(has_pending_reviews_for_graph_exec) update_review_processed_status = _(update_review_processed_status) @@ -313,6 +317,8 @@ class DatabaseManagerAsyncClient(AppServiceClient): set_execution_kv_data = d.set_execution_kv_data # Human In The Loop + cancel_pending_reviews_for_execution = d.cancel_pending_reviews_for_execution + check_approval = d.check_approval get_or_create_human_review = d.get_or_create_human_review update_review_processed_status = d.update_review_processed_status diff --git a/autogpt_platform/backend/backend/executor/utils.py b/autogpt_platform/backend/backend/executor/utils.py index 7771c3751c..f35bebb125 100644 --- a/autogpt_platform/backend/backend/executor/utils.py +++ b/autogpt_platform/backend/backend/executor/utils.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, JsonValue, ValidationError from backend.data import execution as execution_db from backend.data import graph as graph_db +from backend.data import human_review as human_review_db from backend.data import onboarding as onboarding_db from backend.data import user as user_db from backend.data.block import ( @@ -749,9 +750,27 @@ async def stop_graph_execution( if graph_exec.status in [ ExecutionStatus.QUEUED, ExecutionStatus.INCOMPLETE, + ExecutionStatus.REVIEW, ]: - # If the graph is still on the queue, we can prevent them from being executed - # by setting the status to TERMINATED. + # If the graph is queued/incomplete/paused for review, terminate immediately + # No need to wait for executor since it's not actively running + + # If graph is in REVIEW status, clean up pending reviews before terminating + if graph_exec.status == ExecutionStatus.REVIEW: + # Use human_review_db if Prisma connected, else database manager + review_db = ( + human_review_db + if prisma.is_connected() + else get_database_manager_async_client() + ) + # Mark all pending reviews as rejected/cancelled + cancelled_count = await review_db.cancel_pending_reviews_for_execution( + graph_exec_id, user_id + ) + logger.info( + f"Cancelled {cancelled_count} pending review(s) for stopped execution {graph_exec_id}" + ) + graph_exec.status = ExecutionStatus.TERMINATED await asyncio.gather( @@ -887,9 +906,28 @@ async def add_graph_execution( nodes_to_skip=nodes_to_skip, execution_context=execution_context, ) - logger.info(f"Publishing execution {graph_exec.id} to execution queue") + logger.info(f"Queueing execution {graph_exec.id}") + + # Update execution status to QUEUED BEFORE publishing to prevent race condition + # where two concurrent requests could both publish the same execution + updated_exec = await edb.update_graph_execution_stats( + graph_exec_id=graph_exec.id, + status=ExecutionStatus.QUEUED, + ) + + # Verify the status update succeeded (prevents duplicate queueing in race conditions) + # If another request already updated the status, this execution will not be QUEUED + if not updated_exec or updated_exec.status != ExecutionStatus.QUEUED: + logger.warning( + f"Skipping queue publish for execution {graph_exec.id} - " + f"status update failed or execution already queued by another request" + ) + return graph_exec + + graph_exec.status = ExecutionStatus.QUEUED # Publish to execution queue for executor to pick up + # This happens AFTER status update to ensure only one request publishes exec_queue = await get_async_execution_queue() await exec_queue.publish_message( routing_key=GRAPH_EXECUTION_ROUTING_KEY, @@ -897,13 +935,6 @@ async def add_graph_execution( exchange=GRAPH_EXECUTION_EXCHANGE, ) logger.info(f"Published execution {graph_exec.id} to RabbitMQ queue") - - # Update execution status to QUEUED - graph_exec.status = ExecutionStatus.QUEUED - await edb.update_graph_execution_stats( - graph_exec_id=graph_exec.id, - status=graph_exec.status, - ) except BaseException as e: err = str(e) or type(e).__name__ if not graph_exec: diff --git a/autogpt_platform/backend/backend/executor/utils_test.py b/autogpt_platform/backend/backend/executor/utils_test.py index e6e8fcbf60..4761a18c63 100644 --- a/autogpt_platform/backend/backend/executor/utils_test.py +++ b/autogpt_platform/backend/backend/executor/utils_test.py @@ -4,6 +4,7 @@ import pytest from pytest_mock import MockerFixture from backend.data.dynamic_fields import merge_execution_input, parse_execution_output +from backend.data.execution import ExecutionStatus from backend.util.mock import MockObject @@ -346,6 +347,7 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture): mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes) mock_graph_exec.id = "execution-id-123" mock_graph_exec.node_executions = [] # Add this to avoid AttributeError + mock_graph_exec.status = ExecutionStatus.QUEUED # Required for race condition check mock_graph_exec.to_graph_execution_entry.return_value = mocker.MagicMock() # Mock the queue and event bus @@ -611,6 +613,7 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture): mock_graph_exec = mocker.MagicMock(spec=GraphExecutionWithNodes) mock_graph_exec.id = "execution-id-123" mock_graph_exec.node_executions = [] + mock_graph_exec.status = ExecutionStatus.QUEUED # Required for race condition check # Track what's passed to to_graph_execution_entry captured_kwargs = {} @@ -670,3 +673,232 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture): # Verify nodes_to_skip was passed to to_graph_execution_entry assert "nodes_to_skip" in captured_kwargs assert captured_kwargs["nodes_to_skip"] == nodes_to_skip + + +@pytest.mark.asyncio +async def test_stop_graph_execution_in_review_status_cancels_pending_reviews( + mocker: MockerFixture, +): + """Test that stopping an execution in REVIEW status cancels pending reviews.""" + from backend.data.execution import ExecutionStatus, GraphExecutionMeta + from backend.executor.utils import stop_graph_execution + + user_id = "test-user" + graph_exec_id = "test-exec-123" + + # Mock graph execution in REVIEW status + mock_graph_exec = mocker.MagicMock(spec=GraphExecutionMeta) + mock_graph_exec.id = graph_exec_id + mock_graph_exec.status = ExecutionStatus.REVIEW + + # Mock dependencies + mock_get_queue = mocker.patch("backend.executor.utils.get_async_execution_queue") + mock_queue_client = mocker.AsyncMock() + mock_get_queue.return_value = mock_queue_client + + mock_prisma = mocker.patch("backend.executor.utils.prisma") + mock_prisma.is_connected.return_value = True + + mock_human_review_db = mocker.patch("backend.executor.utils.human_review_db") + mock_human_review_db.cancel_pending_reviews_for_execution = mocker.AsyncMock( + return_value=2 # 2 reviews cancelled + ) + + mock_execution_db = mocker.patch("backend.executor.utils.execution_db") + mock_execution_db.get_graph_execution_meta = mocker.AsyncMock( + return_value=mock_graph_exec + ) + mock_execution_db.update_graph_execution_stats = mocker.AsyncMock() + + mock_get_event_bus = mocker.patch( + "backend.executor.utils.get_async_execution_event_bus" + ) + mock_event_bus = mocker.MagicMock() + mock_event_bus.publish = mocker.AsyncMock() + mock_get_event_bus.return_value = mock_event_bus + + mock_get_child_executions = mocker.patch( + "backend.executor.utils._get_child_executions" + ) + mock_get_child_executions.return_value = [] # No children + + # Call stop_graph_execution with timeout to allow status check + await stop_graph_execution( + user_id=user_id, + graph_exec_id=graph_exec_id, + wait_timeout=1.0, # Wait to allow status check + cascade=True, + ) + + # Verify pending reviews were cancelled + mock_human_review_db.cancel_pending_reviews_for_execution.assert_called_once_with( + graph_exec_id, user_id + ) + + # Verify execution status was updated to TERMINATED + mock_execution_db.update_graph_execution_stats.assert_called_once() + call_kwargs = mock_execution_db.update_graph_execution_stats.call_args[1] + assert call_kwargs["graph_exec_id"] == graph_exec_id + assert call_kwargs["status"] == ExecutionStatus.TERMINATED + + +@pytest.mark.asyncio +async def test_stop_graph_execution_with_database_manager_when_prisma_disconnected( + mocker: MockerFixture, +): + """Test that stop uses database manager when Prisma is not connected.""" + from backend.data.execution import ExecutionStatus, GraphExecutionMeta + from backend.executor.utils import stop_graph_execution + + user_id = "test-user" + graph_exec_id = "test-exec-456" + + # Mock graph execution in REVIEW status + mock_graph_exec = mocker.MagicMock(spec=GraphExecutionMeta) + mock_graph_exec.id = graph_exec_id + mock_graph_exec.status = ExecutionStatus.REVIEW + + # Mock dependencies + mock_get_queue = mocker.patch("backend.executor.utils.get_async_execution_queue") + mock_queue_client = mocker.AsyncMock() + mock_get_queue.return_value = mock_queue_client + + # Prisma is NOT connected + mock_prisma = mocker.patch("backend.executor.utils.prisma") + mock_prisma.is_connected.return_value = False + + # Mock database manager client + mock_get_db_manager = mocker.patch( + "backend.executor.utils.get_database_manager_async_client" + ) + mock_db_manager = mocker.AsyncMock() + mock_db_manager.get_graph_execution_meta = mocker.AsyncMock( + return_value=mock_graph_exec + ) + mock_db_manager.cancel_pending_reviews_for_execution = mocker.AsyncMock( + return_value=3 # 3 reviews cancelled + ) + mock_db_manager.update_graph_execution_stats = mocker.AsyncMock() + mock_get_db_manager.return_value = mock_db_manager + + mock_get_event_bus = mocker.patch( + "backend.executor.utils.get_async_execution_event_bus" + ) + mock_event_bus = mocker.MagicMock() + mock_event_bus.publish = mocker.AsyncMock() + mock_get_event_bus.return_value = mock_event_bus + + mock_get_child_executions = mocker.patch( + "backend.executor.utils._get_child_executions" + ) + mock_get_child_executions.return_value = [] # No children + + # Call stop_graph_execution with timeout + await stop_graph_execution( + user_id=user_id, + graph_exec_id=graph_exec_id, + wait_timeout=1.0, + cascade=True, + ) + + # Verify database manager was used for cancel_pending_reviews + mock_db_manager.cancel_pending_reviews_for_execution.assert_called_once_with( + graph_exec_id, user_id + ) + + # Verify execution status was updated via database manager + mock_db_manager.update_graph_execution_stats.assert_called_once() + + +@pytest.mark.asyncio +async def test_stop_graph_execution_cascades_to_child_with_reviews( + mocker: MockerFixture, +): + """Test that stopping parent execution cascades to children and cancels their reviews.""" + from backend.data.execution import ExecutionStatus, GraphExecutionMeta + from backend.executor.utils import stop_graph_execution + + user_id = "test-user" + parent_exec_id = "parent-exec" + child_exec_id = "child-exec" + + # Mock parent execution in RUNNING status + mock_parent_exec = mocker.MagicMock(spec=GraphExecutionMeta) + mock_parent_exec.id = parent_exec_id + mock_parent_exec.status = ExecutionStatus.RUNNING + + # Mock child execution in REVIEW status + mock_child_exec = mocker.MagicMock(spec=GraphExecutionMeta) + mock_child_exec.id = child_exec_id + mock_child_exec.status = ExecutionStatus.REVIEW + + # Mock dependencies + mock_get_queue = mocker.patch("backend.executor.utils.get_async_execution_queue") + mock_queue_client = mocker.AsyncMock() + mock_get_queue.return_value = mock_queue_client + + mock_prisma = mocker.patch("backend.executor.utils.prisma") + mock_prisma.is_connected.return_value = True + + mock_human_review_db = mocker.patch("backend.executor.utils.human_review_db") + mock_human_review_db.cancel_pending_reviews_for_execution = mocker.AsyncMock( + return_value=1 # 1 child review cancelled + ) + + # Mock execution_db to return different status based on which execution is queried + mock_execution_db = mocker.patch("backend.executor.utils.execution_db") + + # Track call count to simulate status transition + call_count = {"count": 0} + + async def get_exec_meta_side_effect(execution_id, user_id): + call_count["count"] += 1 + if execution_id == parent_exec_id: + # After a few calls (child processing happens), transition parent to TERMINATED + # This simulates the executor service processing the stop request + if call_count["count"] > 3: + mock_parent_exec.status = ExecutionStatus.TERMINATED + return mock_parent_exec + elif execution_id == child_exec_id: + return mock_child_exec + return None + + mock_execution_db.get_graph_execution_meta = mocker.AsyncMock( + side_effect=get_exec_meta_side_effect + ) + mock_execution_db.update_graph_execution_stats = mocker.AsyncMock() + + mock_get_event_bus = mocker.patch( + "backend.executor.utils.get_async_execution_event_bus" + ) + mock_event_bus = mocker.MagicMock() + mock_event_bus.publish = mocker.AsyncMock() + mock_get_event_bus.return_value = mock_event_bus + + # Mock _get_child_executions to return the child + mock_get_child_executions = mocker.patch( + "backend.executor.utils._get_child_executions" + ) + + def get_children_side_effect(parent_id): + if parent_id == parent_exec_id: + return [mock_child_exec] + return [] + + mock_get_child_executions.side_effect = get_children_side_effect + + # Call stop_graph_execution on parent with cascade=True + await stop_graph_execution( + user_id=user_id, + graph_exec_id=parent_exec_id, + wait_timeout=1.0, + cascade=True, + ) + + # Verify child reviews were cancelled + mock_human_review_db.cancel_pending_reviews_for_execution.assert_called_once_with( + child_exec_id, user_id + ) + + # Verify both parent and child status updates + assert mock_execution_db.update_graph_execution_stats.call_count >= 1 diff --git a/autogpt_platform/backend/backend/util/test.py b/autogpt_platform/backend/backend/util/test.py index 1e8244ff8e..0a539644ee 100644 --- a/autogpt_platform/backend/backend/util/test.py +++ b/autogpt_platform/backend/backend/util/test.py @@ -1,3 +1,4 @@ +import asyncio import inspect import logging import time @@ -58,6 +59,11 @@ class SpinTestServer: self.db_api.__exit__(exc_type, exc_val, exc_tb) self.notif_manager.__exit__(exc_type, exc_val, exc_tb) + # Give services time to fully shut down + # This prevents event loop issues where services haven't fully cleaned up + # before the next test starts + await asyncio.sleep(0.5) + def setup_dependency_overrides(self): # Override get_user_id for testing self.agent_server.set_test_dependency_overrides( diff --git a/autogpt_platform/backend/migrations/20260121200000_remove_node_execution_fk_from_pending_human_review/migration.sql b/autogpt_platform/backend/migrations/20260121200000_remove_node_execution_fk_from_pending_human_review/migration.sql new file mode 100644 index 0000000000..c43cb0b1e0 --- /dev/null +++ b/autogpt_platform/backend/migrations/20260121200000_remove_node_execution_fk_from_pending_human_review/migration.sql @@ -0,0 +1,7 @@ +-- Remove NodeExecution foreign key from PendingHumanReview +-- The nodeExecId column remains as the primary key, but we remove the FK constraint +-- to AgentNodeExecution since PendingHumanReview records can persist after node +-- execution records are deleted. + +-- Drop foreign key constraint that linked PendingHumanReview.nodeExecId to AgentNodeExecution.id +ALTER TABLE "PendingHumanReview" DROP CONSTRAINT IF EXISTS "PendingHumanReview_nodeExecId_fkey"; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 4a2a7b583a..de94600820 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -517,8 +517,6 @@ model AgentNodeExecution { stats Json? - PendingHumanReview PendingHumanReview? - @@index([agentGraphExecutionId, agentNodeId, executionStatus]) @@index([agentNodeId, executionStatus]) @@index([addedTime, queuedTime]) @@ -567,6 +565,7 @@ enum ReviewStatus { } // Pending human reviews for Human-in-the-loop blocks +// Also stores auto-approval records with special nodeExecId patterns (e.g., "auto_approve_{graph_exec_id}_{node_id}") model PendingHumanReview { nodeExecId String @id userId String @@ -585,7 +584,6 @@ model PendingHumanReview { reviewedAt DateTime? User User @relation(fields: [userId], references: [id], onDelete: Cascade) - NodeExecution AgentNodeExecution @relation(fields: [nodeExecId], references: [id], onDelete: Cascade) GraphExecution AgentGraphExecution @relation(fields: [graphExecId], references: [id], onDelete: Cascade) @@unique([nodeExecId]) // One pending review per node execution diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx index 6c8cbb1a86..227d892fff 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx @@ -86,7 +86,6 @@ export function FloatingSafeModeToggle({ const { currentHITLSafeMode, showHITLToggle, - isHITLStateUndetermined, handleHITLToggle, currentSensitiveActionSafeMode, showSensitiveActionToggle, @@ -99,16 +98,9 @@ export function FloatingSafeModeToggle({ return null; } - const showHITL = showHITLToggle && !isHITLStateUndetermined; - const showSensitive = showSensitiveActionToggle; - - if (!showHITL && !showSensitive) { - return null; - } - return (
- {showHITL && ( + {showHITLToggle && ( )} - {showSensitive && ( + {showSensitiveActionToggle && ( void) | null>( + null, + ); const contentRef = useRef(null); + const { shouldShowPopup, dismissPopup } = useAIAgentSafetyPopup( + agent.id, + agent.has_sensitive_action, + agent.has_human_in_the_loop, + ); + const hasAnySetupFields = Object.keys(agentInputFields || {}).length > 0 || Object.keys(agentCredentialsInputFields || {}).length > 0; @@ -165,6 +179,24 @@ export function RunAgentModal({ onScheduleCreated?.(schedule); } + function handleRunWithSafetyCheck() { + if (shouldShowPopup) { + setPendingRunAction(() => handleRun); + setIsSafetyPopupOpen(true); + } else { + handleRun(); + } + } + + function handleSafetyPopupAcknowledge() { + setIsSafetyPopupOpen(false); + dismissPopup(); + if (pendingRunAction) { + pendingRunAction(); + setPendingRunAction(null); + } + } + return ( <> + + ); } diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/AIAgentSafetyPopup/AIAgentSafetyPopup.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/AIAgentSafetyPopup/AIAgentSafetyPopup.tsx new file mode 100644 index 0000000000..f2d178b33d --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/AIAgentSafetyPopup/AIAgentSafetyPopup.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { Button } from "@/components/atoms/Button/Button"; +import { Text } from "@/components/atoms/Text/Text"; +import { Dialog } from "@/components/molecules/Dialog/Dialog"; +import { Key, storage } from "@/services/storage/local-storage"; +import { ShieldCheckIcon } from "@phosphor-icons/react"; +import { useCallback, useEffect, useState } from "react"; + +interface Props { + agentId: string; + onAcknowledge: () => void; + isOpen: boolean; +} + +export function AIAgentSafetyPopup({ agentId, onAcknowledge, isOpen }: Props) { + function handleAcknowledge() { + // Add this agent to the list of agents for which popup has been shown + const seenAgentsJson = storage.get(Key.AI_AGENT_SAFETY_POPUP_SHOWN); + const seenAgents: string[] = seenAgentsJson + ? JSON.parse(seenAgentsJson) + : []; + + if (!seenAgents.includes(agentId)) { + seenAgents.push(agentId); + storage.set(Key.AI_AGENT_SAFETY_POPUP_SHOWN, JSON.stringify(seenAgents)); + } + + onAcknowledge(); + } + + if (!isOpen) return null; + + return ( + {} }} + styling={{ maxWidth: "480px" }} + > + +
+
+ +
+ + + Safety Checks Enabled + + + + AI-generated agents may take actions that affect your data or + external systems. + + + + AutoGPT includes safety checks so you'll always have the + opportunity to review and approve sensitive actions before they + happen. + + + +
+
+
+ ); +} + +export function useAIAgentSafetyPopup( + agentId: string, + hasSensitiveAction: boolean, + hasHumanInTheLoop: boolean, +) { + const [shouldShowPopup, setShouldShowPopup] = useState(false); + const [hasChecked, setHasChecked] = useState(false); + + useEffect(() => { + if (hasChecked) return; + + const seenAgentsJson = storage.get(Key.AI_AGENT_SAFETY_POPUP_SHOWN); + const seenAgents: string[] = seenAgentsJson + ? JSON.parse(seenAgentsJson) + : []; + const hasSeenPopupForThisAgent = seenAgents.includes(agentId); + const isRelevantAgent = hasSensitiveAction || hasHumanInTheLoop; + + setShouldShowPopup(!hasSeenPopupForThisAgent && isRelevantAgent); + setHasChecked(true); + }, [agentId, hasSensitiveAction, hasHumanInTheLoop, hasChecked]); + + const dismissPopup = useCallback(() => { + setShouldShowPopup(false); + }, []); + + return { + shouldShowPopup, + dismissPopup, + }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/SafeModeToggle.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/SafeModeToggle.tsx index dc0258c768..0fafa67414 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/SafeModeToggle.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/SafeModeToggle.tsx @@ -69,7 +69,6 @@ export function SafeModeToggle({ graph, className }: Props) { const { currentHITLSafeMode, showHITLToggle, - isHITLStateUndetermined, handleHITLToggle, currentSensitiveActionSafeMode, showSensitiveActionToggle, @@ -78,20 +77,13 @@ export function SafeModeToggle({ graph, className }: Props) { shouldShowToggle, } = useAgentSafeMode(graph); - if (!shouldShowToggle || isHITLStateUndetermined) { - return null; - } - - const showHITL = showHITLToggle && !isHITLStateUndetermined; - const showSensitive = showSensitiveActionToggle; - - if (!showHITL && !showSensitive) { + if (!shouldShowToggle) { return null; } return (
- {showHITL && ( + {showHITLToggle && ( )} - {showSensitive && ( + {showSensitiveActionToggle && ( { + // Note: refetchInterval callback receives raw data before select transform + const rawData = q.state.data as + | { status: number; data?: { status?: string } } + | undefined; + if (rawData?.status !== 200) return false; + + const status = rawData?.data?.status; + if (!status) return false; + + // Poll every 2 seconds while running or in review + if ( + status === AgentExecutionStatus.RUNNING || + status === AgentExecutionStatus.QUEUED || + status === AgentExecutionStatus.INCOMPLETE || + status === AgentExecutionStatus.REVIEW + ) { + return 2000; + } + return false; + }, + refetchIntervalInBackground: true, }, }, ); @@ -40,28 +63,47 @@ export function FloatingReviewsPanel({ useShallow((state) => state.graphExecutionStatus), ); + // Determine if we should poll for pending reviews + const isInReviewStatus = + executionDetails?.status === AgentExecutionStatus.REVIEW || + graphExecutionStatus === AgentExecutionStatus.REVIEW; + const { pendingReviews, isLoading, refetch } = usePendingReviewsForExecution( executionId || "", + { + enabled: !!executionId, + // Poll every 2 seconds when in REVIEW status to catch new reviews + refetchInterval: isInReviewStatus ? 2000 : false, + }, ); + // Refetch pending reviews when execution status changes useEffect(() => { - if (executionId) { + if (executionId && executionDetails?.status) { refetch(); } }, [executionDetails?.status, executionId, refetch]); - // Refetch when graph execution status changes to REVIEW - useEffect(() => { - if (graphExecutionStatus === AgentExecutionStatus.REVIEW && executionId) { - refetch(); - } - }, [graphExecutionStatus, executionId, refetch]); + // Hide panel if: + // 1. No execution ID + // 2. No pending reviews and not in REVIEW status + // 3. Execution is RUNNING or QUEUED (hasn't paused for review yet) + if (!executionId) { + return null; + } if ( - !executionId || - (!isLoading && - pendingReviews.length === 0 && - executionDetails?.status !== AgentExecutionStatus.REVIEW) + !isLoading && + pendingReviews.length === 0 && + executionDetails?.status !== AgentExecutionStatus.REVIEW + ) { + return null; + } + + // Don't show panel while execution is still running/queued (not paused for review) + if ( + executionDetails?.status === AgentExecutionStatus.RUNNING || + executionDetails?.status === AgentExecutionStatus.QUEUED ) { return null; } diff --git a/autogpt_platform/frontend/src/components/organisms/PendingReviewCard/PendingReviewCard.tsx b/autogpt_platform/frontend/src/components/organisms/PendingReviewCard/PendingReviewCard.tsx index 3ac636060c..bd456ce771 100644 --- a/autogpt_platform/frontend/src/components/organisms/PendingReviewCard/PendingReviewCard.tsx +++ b/autogpt_platform/frontend/src/components/organisms/PendingReviewCard/PendingReviewCard.tsx @@ -1,10 +1,8 @@ import { PendingHumanReviewModel } from "@/app/api/__generated__/models/pendingHumanReviewModel"; import { Text } from "@/components/atoms/Text/Text"; -import { Button } from "@/components/atoms/Button/Button"; import { Input } from "@/components/atoms/Input/Input"; import { Switch } from "@/components/atoms/Switch/Switch"; -import { TrashIcon, EyeSlashIcon } from "@phosphor-icons/react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; interface StructuredReviewPayload { data: unknown; @@ -40,37 +38,49 @@ function extractReviewData(payload: unknown): { interface PendingReviewCardProps { review: PendingHumanReviewModel; onReviewDataChange: (nodeExecId: string, data: string) => void; - reviewMessage?: string; - onReviewMessageChange?: (nodeExecId: string, message: string) => void; - isDisabled?: boolean; - onToggleDisabled?: (nodeExecId: string) => void; + autoApproveFuture?: boolean; + onAutoApproveFutureChange?: (nodeExecId: string, enabled: boolean) => void; + externalDataValue?: string; + showAutoApprove?: boolean; + nodeId?: string; } export function PendingReviewCard({ review, onReviewDataChange, - reviewMessage = "", - onReviewMessageChange, - isDisabled = false, - onToggleDisabled, + autoApproveFuture = false, + onAutoApproveFutureChange, + externalDataValue, + showAutoApprove = true, + nodeId, }: PendingReviewCardProps) { const extractedData = extractReviewData(review.payload); const isDataEditable = review.editable; - const instructions = extractedData.instructions || review.instructions; + + let instructions = review.instructions; + + const isHITLBlock = instructions && !instructions.includes("Block"); + + if (instructions && !isHITLBlock) { + instructions = undefined; + } + const [currentData, setCurrentData] = useState(extractedData.data); + useEffect(() => { + if (externalDataValue !== undefined) { + try { + const parsedData = JSON.parse(externalDataValue); + setCurrentData(parsedData); + } catch {} + } + }, [externalDataValue]); + const handleDataChange = (newValue: unknown) => { setCurrentData(newValue); onReviewDataChange(review.node_exec_id, JSON.stringify(newValue, null, 2)); }; - const handleMessageChange = (newMessage: string) => { - onReviewMessageChange?.(review.node_exec_id, newMessage); - }; - - // Show simplified view when no toggle functionality is provided (Screenshot 1 mode) - const showSimplified = !onToggleDisabled; - const renderDataInput = () => { const data = currentData; @@ -137,97 +147,59 @@ export function PendingReviewCard({ } }; - // Helper function to get proper field label - const getFieldLabel = (instructions?: string) => { - if (instructions) - return instructions.charAt(0).toUpperCase() + instructions.slice(1); - return "Data to Review"; + const getShortenedNodeId = (id: string) => { + if (id.length <= 8) return id; + return `${id.slice(0, 4)}...${id.slice(-4)}`; }; - // Use the existing HITL review interface return (
- {!showSimplified && ( -
-
- {isDisabled && ( - - This item will be rejected - - )} + {nodeId && ( + + Node #{getShortenedNodeId(nodeId)} + + )} + +
+ {instructions && ( + + {instructions} + + )} + + {isDataEditable && !autoApproveFuture ? ( + renderDataInput() + ) : ( +
+ + {JSON.stringify(currentData, null, 2)} +
- -
- )} + )} +
- {/* Show instructions as field label */} - {instructions && ( -
- - {getFieldLabel(instructions)} - - {isDataEditable && !isDisabled ? ( - renderDataInput() - ) : ( -
- - {JSON.stringify(currentData, null, 2)} - -
+ {/* Auto-approve toggle for this review */} + {showAutoApprove && onAutoApproveFutureChange && ( +
+
+ + onAutoApproveFutureChange(review.node_exec_id, enabled) + } + /> + + Auto-approve future executions of this block + +
+ {autoApproveFuture && ( + + Original data will be used for this and all future reviews from + this block. + )}
)} - - {/* If no instructions, show data directly */} - {!instructions && ( -
- - Data to Review - {!isDataEditable && ( - - (Read-only) - - )} - - {isDataEditable && !isDisabled ? ( - renderDataInput() - ) : ( -
- - {JSON.stringify(currentData, null, 2)} - -
- )} -
- )} - - {!showSimplified && isDisabled && ( -
- - Rejection Reason (Optional): - - handleMessageChange(e.target.value)} - placeholder="Add any notes about why you're rejecting this..." - /> -
- )}
); } diff --git a/autogpt_platform/frontend/src/components/organisms/PendingReviewsList/PendingReviewsList.tsx b/autogpt_platform/frontend/src/components/organisms/PendingReviewsList/PendingReviewsList.tsx index 3253b0ee6d..5adb3919b6 100644 --- a/autogpt_platform/frontend/src/components/organisms/PendingReviewsList/PendingReviewsList.tsx +++ b/autogpt_platform/frontend/src/components/organisms/PendingReviewsList/PendingReviewsList.tsx @@ -1,10 +1,16 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { PendingHumanReviewModel } from "@/app/api/__generated__/models/pendingHumanReviewModel"; import { PendingReviewCard } from "@/components/organisms/PendingReviewCard/PendingReviewCard"; import { Text } from "@/components/atoms/Text/Text"; import { Button } from "@/components/atoms/Button/Button"; +import { Switch } from "@/components/atoms/Switch/Switch"; import { useToast } from "@/components/molecules/Toast/use-toast"; -import { ClockIcon, WarningIcon } from "@phosphor-icons/react"; +import { + ClockIcon, + WarningIcon, + CaretDownIcon, + CaretRightIcon, +} from "@phosphor-icons/react"; import { usePostV2ProcessReviewAction } from "@/app/api/__generated__/endpoints/executions/executions"; interface PendingReviewsListProps { @@ -32,16 +38,34 @@ export function PendingReviewsList({ }, ); - const [reviewMessageMap, setReviewMessageMap] = useState< - Record - >({}); - const [pendingAction, setPendingAction] = useState< "approve" | "reject" | null >(null); + const [autoApproveFutureMap, setAutoApproveFutureMap] = useState< + Record + >({}); + + const [collapsedGroups, setCollapsedGroups] = useState< + Record + >({}); + const { toast } = useToast(); + const groupedReviews = useMemo(() => { + return reviews.reduce( + (acc, review) => { + const nodeId = review.node_id || "unknown"; + if (!acc[nodeId]) { + acc[nodeId] = []; + } + acc[nodeId].push(review); + return acc; + }, + {} as Record, + ); + }, [reviews]); + const reviewActionMutation = usePostV2ProcessReviewAction({ mutation: { onSuccess: (res) => { @@ -88,8 +112,33 @@ export function PendingReviewsList({ setReviewDataMap((prev) => ({ ...prev, [nodeExecId]: data })); } - function handleReviewMessageChange(nodeExecId: string, message: string) { - setReviewMessageMap((prev) => ({ ...prev, [nodeExecId]: message })); + function handleAutoApproveFutureToggle(nodeId: string, enabled: boolean) { + setAutoApproveFutureMap((prev) => ({ + ...prev, + [nodeId]: enabled, + })); + + if (enabled) { + const nodeReviews = groupedReviews[nodeId] || []; + setReviewDataMap((prev) => { + const updated = { ...prev }; + nodeReviews.forEach((review) => { + updated[review.node_exec_id] = JSON.stringify( + review.payload, + null, + 2, + ); + }); + return updated; + }); + } + } + + function toggleGroupCollapse(nodeId: string) { + setCollapsedGroups((prev) => ({ + ...prev, + [nodeId]: !prev[nodeId], + })); } function processReviews(approved: boolean) { @@ -107,22 +156,25 @@ export function PendingReviewsList({ for (const review of reviews) { const reviewData = reviewDataMap[review.node_exec_id]; - const reviewMessage = reviewMessageMap[review.node_exec_id]; + const autoApproveThisNode = autoApproveFutureMap[review.node_id || ""]; - let parsedData: any = review.payload; // Default to original payload + let parsedData: any = undefined; - // Parse edited data if available and editable - if (review.editable && reviewData) { - try { - parsedData = JSON.parse(reviewData); - } catch (error) { - toast({ - title: "Invalid JSON", - description: `Please fix the JSON format in review for node ${review.node_exec_id}: ${error instanceof Error ? error.message : "Invalid syntax"}`, - variant: "destructive", - }); - setPendingAction(null); - return; + if (!autoApproveThisNode) { + if (review.editable && reviewData) { + try { + parsedData = JSON.parse(reviewData); + } catch (error) { + toast({ + title: "Invalid JSON", + description: `Please fix the JSON format in review for node ${review.node_exec_id}: ${error instanceof Error ? error.message : "Invalid syntax"}`, + variant: "destructive", + }); + setPendingAction(null); + return; + } + } else { + parsedData = review.payload; } } @@ -130,7 +182,7 @@ export function PendingReviewsList({ node_exec_id: review.node_exec_id, approved, reviewed_data: parsedData, - message: reviewMessage || undefined, + auto_approve_future: autoApproveThisNode && approved, }); } @@ -158,7 +210,6 @@ export function PendingReviewsList({ return (
- {/* Warning Box Header */}
- {reviews.map((review) => ( - - ))} + {Object.entries(groupedReviews).map(([nodeId, nodeReviews]) => { + const isCollapsed = collapsedGroups[nodeId] ?? nodeReviews.length > 1; + const reviewCount = nodeReviews.length; + + const firstReview = nodeReviews[0]; + const blockName = firstReview?.instructions; + const reviewTitle = `Review required for ${blockName}`; + + const getShortenedNodeId = (id: string) => { + if (id.length <= 8) return id; + return `${id.slice(0, 4)}...${id.slice(-4)}`; + }; + + return ( +
+ + + {!isCollapsed && ( +
+ {nodeReviews.map((review) => ( + + ))} + +
+ + handleAutoApproveFutureToggle(nodeId, enabled) + } + /> + + Auto-approve future executions of this node + +
+
+ )} +
+ ); + })}
-
- - Note: Changes you make here apply only to this task - - -
+
+
+ + + You can turn auto-approval on or off using the toggle above for each + node. +
); diff --git a/autogpt_platform/frontend/src/hooks/usePendingReviews.ts b/autogpt_platform/frontend/src/hooks/usePendingReviews.ts index 8257814fcf..b9d7d711a1 100644 --- a/autogpt_platform/frontend/src/hooks/usePendingReviews.ts +++ b/autogpt_platform/frontend/src/hooks/usePendingReviews.ts @@ -15,8 +15,22 @@ export function usePendingReviews() { }; } -export function usePendingReviewsForExecution(graphExecId: string) { - const query = useGetV2GetPendingReviewsForExecution(graphExecId); +interface UsePendingReviewsForExecutionOptions { + enabled?: boolean; + refetchInterval?: number | false; +} + +export function usePendingReviewsForExecution( + graphExecId: string, + options?: UsePendingReviewsForExecutionOptions, +) { + const query = useGetV2GetPendingReviewsForExecution(graphExecId, { + query: { + enabled: options?.enabled ?? !!graphExecId, + refetchInterval: options?.refetchInterval, + refetchIntervalInBackground: !!options?.refetchInterval, + }, + }); return { pendingReviews: okData(query.data) || [], diff --git a/autogpt_platform/frontend/src/services/storage/local-storage.ts b/autogpt_platform/frontend/src/services/storage/local-storage.ts index 494ddc3ccc..a1aa63741a 100644 --- a/autogpt_platform/frontend/src/services/storage/local-storage.ts +++ b/autogpt_platform/frontend/src/services/storage/local-storage.ts @@ -10,6 +10,7 @@ export enum Key { LIBRARY_AGENTS_CACHE = "library-agents-cache", CHAT_SESSION_ID = "chat_session_id", COOKIE_CONSENT = "autogpt_cookie_consent", + AI_AGENT_SAFETY_POPUP_SHOWN = "ai-agent-safety-popup-shown", } function get(key: Key) { From 9a6e17ff520d892eda9473f1d4e2119aa17af8a6 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Sat, 24 Jan 2026 16:08:56 -0500 Subject: [PATCH 07/36] feat(backend): add external Agent Generator service integration (#11819) ## Summary - Add support for delegating agent generation to an external microservice when `AGENTGENERATOR_HOST` is configured - Falls back to built-in LLM-based implementation when not configured (default behavior) - Add comprehensive tests for the service client and core integration (34 tests) ## Changes - Add `agentgenerator_host`, `agentgenerator_port`, `agentgenerator_timeout` settings to `backend/util/settings.py` - Add `service.py` client for external Agent Generator API endpoints: - `/api/decompose-description` - Break down goals into steps - `/api/generate-agent` - Generate agent from instructions - `/api/update-agent` - Generate patches to update existing agents - `/api/blocks` - Get available blocks - `/health` - Health check - Update `core.py` to delegate to external service when configured - Export `is_external_service_configured` and `check_external_service_health` from the module ## Related PRs - Infrastructure repo: https://github.com/Significant-Gravitas/AutoGPT-cloud-infrastructure/pull/273 ## Test plan - [x] All 34 new tests pass (`poetry run pytest test/agent_generator/ -v`) - [ ] Deploy with `AGENTGENERATOR_HOST` configured and verify external service is used - [ ] Verify built-in implementation still works when `AGENTGENERATOR_HOST` is empty --- .../chat/tools/agent_generator/__init__.py | 21 +- .../chat/tools/agent_generator/client.py | 25 - .../chat/tools/agent_generator/core.py | 218 ++----- .../chat/tools/agent_generator/fixer.py | 606 ------------------ .../chat/tools/agent_generator/prompts.py | 225 ------- .../chat/tools/agent_generator/service.py | 269 ++++++++ .../chat/tools/agent_generator/utils.py | 213 ------ .../chat/tools/agent_generator/validator.py | 279 -------- .../api/features/chat/tools/create_agent.py | 98 +-- .../api/features/chat/tools/edit_agent.py | 163 ++--- .../backend/backend/util/settings.py | 13 + .../backend/test/agent_generator/__init__.py | 1 + .../agent_generator/test_core_integration.py | 273 ++++++++ .../test/agent_generator/test_service.py | 422 ++++++++++++ 14 files changed, 1112 insertions(+), 1714 deletions(-) delete mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/client.py delete mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/fixer.py delete mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/prompts.py create mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py delete mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/utils.py delete mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/validator.py create mode 100644 autogpt_platform/backend/test/agent_generator/__init__.py create mode 100644 autogpt_platform/backend/test/agent_generator/test_core_integration.py create mode 100644 autogpt_platform/backend/test/agent_generator/test_service.py diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/__init__.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/__init__.py index d4df2564a8..392f642c41 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/__init__.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/__init__.py @@ -1,29 +1,28 @@ """Agent generator package - Creates agents from natural language.""" from .core import ( - apply_agent_patch, + AgentGeneratorNotConfiguredError, decompose_goal, generate_agent, generate_agent_patch, get_agent_as_json, + json_to_graph, save_agent_to_library, ) -from .fixer import apply_all_fixes -from .utils import get_blocks_info -from .validator import validate_agent +from .service import health_check as check_external_service_health +from .service import is_external_service_configured __all__ = [ # Core functions "decompose_goal", "generate_agent", "generate_agent_patch", - "apply_agent_patch", "save_agent_to_library", "get_agent_as_json", - # Fixer - "apply_all_fixes", - # Validator - "validate_agent", - # Utils - "get_blocks_info", + "json_to_graph", + # Exceptions + "AgentGeneratorNotConfiguredError", + # Service + "is_external_service_configured", + "check_external_service_health", ] diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/client.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/client.py deleted file mode 100644 index 4450fa9d75..0000000000 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/client.py +++ /dev/null @@ -1,25 +0,0 @@ -"""OpenRouter client configuration for agent generation.""" - -import os - -from openai import AsyncOpenAI - -# Configuration - use OPEN_ROUTER_API_KEY for consistency with chat/config.py -OPENROUTER_API_KEY = os.getenv("OPEN_ROUTER_API_KEY") -AGENT_GENERATOR_MODEL = os.getenv("AGENT_GENERATOR_MODEL", "anthropic/claude-opus-4.5") - -# OpenRouter client (OpenAI-compatible API) -_client: AsyncOpenAI | None = None - - -def get_client() -> AsyncOpenAI: - """Get or create the OpenRouter client.""" - global _client - if _client is None: - if not OPENROUTER_API_KEY: - raise ValueError("OPENROUTER_API_KEY environment variable is required") - _client = AsyncOpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=OPENROUTER_API_KEY, - ) - return _client diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/core.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/core.py index 0f94135a41..fc15587110 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/core.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/core.py @@ -1,7 +1,5 @@ """Core agent generation functions.""" -import copy -import json import logging import uuid from typing import Any @@ -9,13 +7,35 @@ from typing import Any from backend.api.features.library import db as library_db from backend.data.graph import Graph, Link, Node, create_graph -from .client import AGENT_GENERATOR_MODEL, get_client -from .prompts import DECOMPOSITION_PROMPT, GENERATION_PROMPT, PATCH_PROMPT -from .utils import get_block_summaries, parse_json_from_llm +from .service import ( + decompose_goal_external, + generate_agent_external, + generate_agent_patch_external, + is_external_service_configured, +) logger = logging.getLogger(__name__) +class AgentGeneratorNotConfiguredError(Exception): + """Raised when the external Agent Generator service is not configured.""" + + pass + + +def _check_service_configured() -> None: + """Check if the external Agent Generator service is configured. + + Raises: + AgentGeneratorNotConfiguredError: If the service is not configured. + """ + if not is_external_service_configured(): + raise AgentGeneratorNotConfiguredError( + "Agent Generator service is not configured. " + "Set AGENTGENERATOR_HOST environment variable to enable agent generation." + ) + + async def decompose_goal(description: str, context: str = "") -> dict[str, Any] | None: """Break down a goal into steps or return clarifying questions. @@ -28,40 +48,13 @@ async def decompose_goal(description: str, context: str = "") -> dict[str, Any] - {"type": "clarifying_questions", "questions": [...]} - {"type": "instructions", "steps": [...]} Or None on error + + Raises: + AgentGeneratorNotConfiguredError: If the external service is not configured. """ - client = get_client() - prompt = DECOMPOSITION_PROMPT.format(block_summaries=get_block_summaries()) - - full_description = description - if context: - full_description = f"{description}\n\nAdditional context:\n{context}" - - try: - response = await client.chat.completions.create( - model=AGENT_GENERATOR_MODEL, - messages=[ - {"role": "system", "content": prompt}, - {"role": "user", "content": full_description}, - ], - temperature=0, - ) - - content = response.choices[0].message.content - if content is None: - logger.error("LLM returned empty content for decomposition") - return None - - result = parse_json_from_llm(content) - - if result is None: - logger.error(f"Failed to parse decomposition response: {content[:200]}") - return None - - return result - - except Exception as e: - logger.error(f"Error decomposing goal: {e}") - return None + _check_service_configured() + logger.info("Calling external Agent Generator service for decompose_goal") + return await decompose_goal_external(description, context) async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None: @@ -72,31 +65,14 @@ async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None: Returns: Agent JSON dict or None on error + + Raises: + AgentGeneratorNotConfiguredError: If the external service is not configured. """ - client = get_client() - prompt = GENERATION_PROMPT.format(block_summaries=get_block_summaries()) - - try: - response = await client.chat.completions.create( - model=AGENT_GENERATOR_MODEL, - messages=[ - {"role": "system", "content": prompt}, - {"role": "user", "content": json.dumps(instructions, indent=2)}, - ], - temperature=0, - ) - - content = response.choices[0].message.content - if content is None: - logger.error("LLM returned empty content for agent generation") - return None - - result = parse_json_from_llm(content) - - if result is None: - logger.error(f"Failed to parse agent JSON: {content[:200]}") - return None - + _check_service_configured() + logger.info("Calling external Agent Generator service for generate_agent") + result = await generate_agent_external(instructions) + if result: # Ensure required fields if "id" not in result: result["id"] = str(uuid.uuid4()) @@ -104,12 +80,7 @@ async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None: result["version"] = 1 if "is_active" not in result: result["is_active"] = True - - return result - - except Exception as e: - logger.error(f"Error generating agent: {e}") - return None + return result def json_to_graph(agent_json: dict[str, Any]) -> Graph: @@ -284,108 +255,23 @@ async def get_agent_as_json( async def generate_agent_patch( update_request: str, current_agent: dict[str, Any] ) -> dict[str, Any] | None: - """Generate a patch to update an existing agent. + """Update an existing agent using natural language. + + The external Agent Generator service handles: + - Generating the patch + - Applying the patch + - Fixing and validating the result Args: update_request: Natural language description of changes current_agent: Current agent JSON Returns: - Patch dict or clarifying questions, or None on error + Updated agent JSON, clarifying questions dict, or None on error + + Raises: + AgentGeneratorNotConfiguredError: If the external service is not configured. """ - client = get_client() - prompt = PATCH_PROMPT.format( - current_agent=json.dumps(current_agent, indent=2), - block_summaries=get_block_summaries(), - ) - - try: - response = await client.chat.completions.create( - model=AGENT_GENERATOR_MODEL, - messages=[ - {"role": "system", "content": prompt}, - {"role": "user", "content": update_request}, - ], - temperature=0, - ) - - content = response.choices[0].message.content - if content is None: - logger.error("LLM returned empty content for patch generation") - return None - - return parse_json_from_llm(content) - - except Exception as e: - logger.error(f"Error generating patch: {e}") - return None - - -def apply_agent_patch( - current_agent: dict[str, Any], patch: dict[str, Any] -) -> dict[str, Any]: - """Apply a patch to an existing agent. - - Args: - current_agent: Current agent JSON - patch: Patch dict with operations - - Returns: - Updated agent JSON - """ - agent = copy.deepcopy(current_agent) - patches = patch.get("patches", []) - - for p in patches: - patch_type = p.get("type") - - if patch_type == "modify": - node_id = p.get("node_id") - changes = p.get("changes", {}) - - for node in agent.get("nodes", []): - if node["id"] == node_id: - _deep_update(node, changes) - logger.debug(f"Modified node {node_id}") - break - - elif patch_type == "add": - new_nodes = p.get("new_nodes", []) - new_links = p.get("new_links", []) - - agent["nodes"] = agent.get("nodes", []) + new_nodes - agent["links"] = agent.get("links", []) + new_links - logger.debug(f"Added {len(new_nodes)} nodes, {len(new_links)} links") - - elif patch_type == "remove": - node_ids_to_remove = set(p.get("node_ids", [])) - link_ids_to_remove = set(p.get("link_ids", [])) - - # Remove nodes - agent["nodes"] = [ - n for n in agent.get("nodes", []) if n["id"] not in node_ids_to_remove - ] - - # Remove links (both explicit and those referencing removed nodes) - agent["links"] = [ - link - for link in agent.get("links", []) - if link["id"] not in link_ids_to_remove - and link["source_id"] not in node_ids_to_remove - and link["sink_id"] not in node_ids_to_remove - ] - - logger.debug( - f"Removed {len(node_ids_to_remove)} nodes, {len(link_ids_to_remove)} links" - ) - - return agent - - -def _deep_update(target: dict, source: dict) -> None: - """Recursively update a dict with another dict.""" - for key, value in source.items(): - if key in target and isinstance(target[key], dict) and isinstance(value, dict): - _deep_update(target[key], value) - else: - target[key] = value + _check_service_configured() + logger.info("Calling external Agent Generator service for generate_agent_patch") + return await generate_agent_patch_external(update_request, current_agent) diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/fixer.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/fixer.py deleted file mode 100644 index 1e25e0cbed..0000000000 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/fixer.py +++ /dev/null @@ -1,606 +0,0 @@ -"""Agent fixer - Fixes common LLM generation errors.""" - -import logging -import re -import uuid -from typing import Any - -from .utils import ( - ADDTODICTIONARY_BLOCK_ID, - ADDTOLIST_BLOCK_ID, - CODE_EXECUTION_BLOCK_ID, - CONDITION_BLOCK_ID, - CREATEDICT_BLOCK_ID, - CREATELIST_BLOCK_ID, - DATA_SAMPLING_BLOCK_ID, - DOUBLE_CURLY_BRACES_BLOCK_IDS, - GET_CURRENT_DATE_BLOCK_ID, - STORE_VALUE_BLOCK_ID, - UNIVERSAL_TYPE_CONVERTER_BLOCK_ID, - get_blocks_info, - is_valid_uuid, -) - -logger = logging.getLogger(__name__) - - -def fix_agent_ids(agent: dict[str, Any]) -> dict[str, Any]: - """Fix invalid UUIDs in agent and link IDs.""" - # Fix agent ID - if not is_valid_uuid(agent.get("id", "")): - agent["id"] = str(uuid.uuid4()) - logger.debug(f"Fixed agent ID: {agent['id']}") - - # Fix node IDs - id_mapping = {} # Old ID -> New ID - for node in agent.get("nodes", []): - if not is_valid_uuid(node.get("id", "")): - old_id = node.get("id", "") - new_id = str(uuid.uuid4()) - id_mapping[old_id] = new_id - node["id"] = new_id - logger.debug(f"Fixed node ID: {old_id} -> {new_id}") - - # Fix link IDs and update references - for link in agent.get("links", []): - if not is_valid_uuid(link.get("id", "")): - link["id"] = str(uuid.uuid4()) - logger.debug(f"Fixed link ID: {link['id']}") - - # Update source/sink IDs if they were remapped - if link.get("source_id") in id_mapping: - link["source_id"] = id_mapping[link["source_id"]] - if link.get("sink_id") in id_mapping: - link["sink_id"] = id_mapping[link["sink_id"]] - - return agent - - -def fix_double_curly_braces(agent: dict[str, Any]) -> dict[str, Any]: - """Fix single curly braces to double in template blocks.""" - for node in agent.get("nodes", []): - if node.get("block_id") not in DOUBLE_CURLY_BRACES_BLOCK_IDS: - continue - - input_data = node.get("input_default", {}) - for key in ("prompt", "format"): - if key in input_data and isinstance(input_data[key], str): - original = input_data[key] - # Fix simple variable references: {var} -> {{var}} - fixed = re.sub( - r"(? dict[str, Any]: - """Add StoreValueBlock before ConditionBlock if needed for value2.""" - nodes = agent.get("nodes", []) - links = agent.get("links", []) - - # Find all ConditionBlock nodes - condition_node_ids = { - node["id"] for node in nodes if node.get("block_id") == CONDITION_BLOCK_ID - } - - if not condition_node_ids: - return agent - - new_nodes = [] - new_links = [] - processed_conditions = set() - - for link in links: - sink_id = link.get("sink_id") - sink_name = link.get("sink_name") - - # Check if this link goes to a ConditionBlock's value2 - if sink_id in condition_node_ids and sink_name == "value2": - source_node = next( - (n for n in nodes if n["id"] == link.get("source_id")), None - ) - - # Skip if source is already a StoreValueBlock - if source_node and source_node.get("block_id") == STORE_VALUE_BLOCK_ID: - continue - - # Skip if we already processed this condition - if sink_id in processed_conditions: - continue - - processed_conditions.add(sink_id) - - # Create StoreValueBlock - store_node_id = str(uuid.uuid4()) - store_node = { - "id": store_node_id, - "block_id": STORE_VALUE_BLOCK_ID, - "input_default": {"data": None}, - "metadata": {"position": {"x": 0, "y": -100}}, - } - new_nodes.append(store_node) - - # Create link: original source -> StoreValueBlock - new_links.append( - { - "id": str(uuid.uuid4()), - "source_id": link["source_id"], - "source_name": link["source_name"], - "sink_id": store_node_id, - "sink_name": "input", - "is_static": False, - } - ) - - # Update original link: StoreValueBlock -> ConditionBlock - link["source_id"] = store_node_id - link["source_name"] = "output" - - logger.debug(f"Added StoreValueBlock before ConditionBlock {sink_id}") - - if new_nodes: - agent["nodes"] = nodes + new_nodes - - return agent - - -def fix_addtolist_blocks(agent: dict[str, Any]) -> dict[str, Any]: - """Fix AddToList blocks by adding prerequisite empty AddToList block. - - When an AddToList block is found: - 1. Checks if there's a CreateListBlock before it - 2. Removes CreateListBlock if linked directly to AddToList - 3. Adds an empty AddToList block before the original - 4. Ensures the original has a self-referencing link - """ - nodes = agent.get("nodes", []) - links = agent.get("links", []) - new_nodes = [] - original_addtolist_ids = set() - nodes_to_remove = set() - links_to_remove = [] - - # First pass: identify CreateListBlock nodes to remove - for link in links: - source_node = next( - (n for n in nodes if n.get("id") == link.get("source_id")), None - ) - sink_node = next((n for n in nodes if n.get("id") == link.get("sink_id")), None) - - if ( - source_node - and sink_node - and source_node.get("block_id") == CREATELIST_BLOCK_ID - and sink_node.get("block_id") == ADDTOLIST_BLOCK_ID - ): - nodes_to_remove.add(source_node.get("id")) - links_to_remove.append(link) - logger.debug(f"Removing CreateListBlock {source_node.get('id')}") - - # Second pass: process AddToList blocks - filtered_nodes = [] - for node in nodes: - if node.get("id") in nodes_to_remove: - continue - - if node.get("block_id") == ADDTOLIST_BLOCK_ID: - original_addtolist_ids.add(node.get("id")) - node_id = node.get("id") - pos = node.get("metadata", {}).get("position", {"x": 0, "y": 0}) - - # Check if already has prerequisite - has_prereq = any( - link.get("sink_id") == node_id - and link.get("sink_name") == "list" - and link.get("source_name") == "updated_list" - for link in links - ) - - if not has_prereq: - # Remove links to "list" input (except self-reference) - for link in links: - if ( - link.get("sink_id") == node_id - and link.get("sink_name") == "list" - and link.get("source_id") != node_id - and link not in links_to_remove - ): - links_to_remove.append(link) - - # Create prerequisite AddToList block - prereq_id = str(uuid.uuid4()) - prereq_node = { - "id": prereq_id, - "block_id": ADDTOLIST_BLOCK_ID, - "input_default": {"list": [], "entry": None, "entries": []}, - "metadata": { - "position": {"x": pos.get("x", 0) - 800, "y": pos.get("y", 0)} - }, - } - new_nodes.append(prereq_node) - - # Link prerequisite to original - links.append( - { - "id": str(uuid.uuid4()), - "source_id": prereq_id, - "source_name": "updated_list", - "sink_id": node_id, - "sink_name": "list", - "is_static": False, - } - ) - logger.debug(f"Added prerequisite AddToList block for {node_id}") - - filtered_nodes.append(node) - - # Remove marked links - filtered_links = [link for link in links if link not in links_to_remove] - - # Add self-referencing links for original AddToList blocks - for node in filtered_nodes + new_nodes: - if ( - node.get("block_id") == ADDTOLIST_BLOCK_ID - and node.get("id") in original_addtolist_ids - ): - node_id = node.get("id") - has_self_ref = any( - link["source_id"] == node_id - and link["sink_id"] == node_id - and link["source_name"] == "updated_list" - and link["sink_name"] == "list" - for link in filtered_links - ) - if not has_self_ref: - filtered_links.append( - { - "id": str(uuid.uuid4()), - "source_id": node_id, - "source_name": "updated_list", - "sink_id": node_id, - "sink_name": "list", - "is_static": False, - } - ) - logger.debug(f"Added self-reference for AddToList {node_id}") - - agent["nodes"] = filtered_nodes + new_nodes - agent["links"] = filtered_links - return agent - - -def fix_addtodictionary_blocks(agent: dict[str, Any]) -> dict[str, Any]: - """Fix AddToDictionary blocks by removing empty CreateDictionary nodes.""" - nodes = agent.get("nodes", []) - links = agent.get("links", []) - nodes_to_remove = set() - links_to_remove = [] - - for link in links: - source_node = next( - (n for n in nodes if n.get("id") == link.get("source_id")), None - ) - sink_node = next((n for n in nodes if n.get("id") == link.get("sink_id")), None) - - if ( - source_node - and sink_node - and source_node.get("block_id") == CREATEDICT_BLOCK_ID - and sink_node.get("block_id") == ADDTODICTIONARY_BLOCK_ID - ): - nodes_to_remove.add(source_node.get("id")) - links_to_remove.append(link) - logger.debug(f"Removing CreateDictionary {source_node.get('id')}") - - agent["nodes"] = [n for n in nodes if n.get("id") not in nodes_to_remove] - agent["links"] = [link for link in links if link not in links_to_remove] - return agent - - -def fix_code_execution_output(agent: dict[str, Any]) -> dict[str, Any]: - """Fix CodeExecutionBlock output: change 'response' to 'stdout_logs'.""" - nodes = agent.get("nodes", []) - links = agent.get("links", []) - - for link in links: - source_node = next( - (n for n in nodes if n.get("id") == link.get("source_id")), None - ) - if ( - source_node - and source_node.get("block_id") == CODE_EXECUTION_BLOCK_ID - and link.get("source_name") == "response" - ): - link["source_name"] = "stdout_logs" - logger.debug("Fixed CodeExecutionBlock output: response -> stdout_logs") - - return agent - - -def fix_data_sampling_sample_size(agent: dict[str, Any]) -> dict[str, Any]: - """Fix DataSamplingBlock by setting sample_size to 1 as default.""" - nodes = agent.get("nodes", []) - links = agent.get("links", []) - links_to_remove = [] - - for node in nodes: - if node.get("block_id") == DATA_SAMPLING_BLOCK_ID: - node_id = node.get("id") - input_default = node.get("input_default", {}) - - # Remove links to sample_size - for link in links: - if ( - link.get("sink_id") == node_id - and link.get("sink_name") == "sample_size" - ): - links_to_remove.append(link) - - # Set default - input_default["sample_size"] = 1 - node["input_default"] = input_default - logger.debug(f"Fixed DataSamplingBlock {node_id} sample_size to 1") - - if links_to_remove: - agent["links"] = [link for link in links if link not in links_to_remove] - - return agent - - -def fix_node_x_coordinates(agent: dict[str, Any]) -> dict[str, Any]: - """Fix node x-coordinates to ensure 800+ unit spacing between linked nodes.""" - nodes = agent.get("nodes", []) - links = agent.get("links", []) - node_lookup = {n.get("id"): n for n in nodes} - - for link in links: - source_id = link.get("source_id") - sink_id = link.get("sink_id") - - source_node = node_lookup.get(source_id) - sink_node = node_lookup.get(sink_id) - - if not source_node or not sink_node: - continue - - source_pos = source_node.get("metadata", {}).get("position", {}) - sink_pos = sink_node.get("metadata", {}).get("position", {}) - - source_x = source_pos.get("x", 0) - sink_x = sink_pos.get("x", 0) - - if abs(sink_x - source_x) < 800: - new_x = source_x + 800 - if "metadata" not in sink_node: - sink_node["metadata"] = {} - if "position" not in sink_node["metadata"]: - sink_node["metadata"]["position"] = {} - sink_node["metadata"]["position"]["x"] = new_x - logger.debug(f"Fixed node {sink_id} x: {sink_x} -> {new_x}") - - return agent - - -def fix_getcurrentdate_offset(agent: dict[str, Any]) -> dict[str, Any]: - """Fix GetCurrentDateBlock offset to ensure it's positive.""" - for node in agent.get("nodes", []): - if node.get("block_id") == GET_CURRENT_DATE_BLOCK_ID: - input_default = node.get("input_default", {}) - if "offset" in input_default: - offset = input_default["offset"] - if isinstance(offset, (int, float)) and offset < 0: - input_default["offset"] = abs(offset) - logger.debug(f"Fixed offset: {offset} -> {abs(offset)}") - - return agent - - -def fix_ai_model_parameter( - agent: dict[str, Any], - blocks_info: list[dict[str, Any]], - default_model: str = "gpt-4o", -) -> dict[str, Any]: - """Add default model parameter to AI blocks if missing.""" - block_map = {b.get("id"): b for b in blocks_info} - - for node in agent.get("nodes", []): - block_id = node.get("block_id") - block = block_map.get(block_id) - - if not block: - continue - - # Check if block has AI category - categories = block.get("categories", []) - is_ai_block = any( - cat.get("category") == "AI" for cat in categories if isinstance(cat, dict) - ) - - if is_ai_block: - input_default = node.get("input_default", {}) - if "model" not in input_default: - input_default["model"] = default_model - node["input_default"] = input_default - logger.debug( - f"Added model '{default_model}' to AI block {node.get('id')}" - ) - - return agent - - -def fix_link_static_properties( - agent: dict[str, Any], blocks_info: list[dict[str, Any]] -) -> dict[str, Any]: - """Fix is_static property based on source block's staticOutput.""" - block_map = {b.get("id"): b for b in blocks_info} - node_lookup = {n.get("id"): n for n in agent.get("nodes", [])} - - for link in agent.get("links", []): - source_node = node_lookup.get(link.get("source_id")) - if not source_node: - continue - - source_block = block_map.get(source_node.get("block_id")) - if not source_block: - continue - - static_output = source_block.get("staticOutput", False) - if link.get("is_static") != static_output: - link["is_static"] = static_output - logger.debug(f"Fixed link {link.get('id')} is_static to {static_output}") - - return agent - - -def fix_data_type_mismatch( - agent: dict[str, Any], blocks_info: list[dict[str, Any]] -) -> dict[str, Any]: - """Fix data type mismatches by inserting UniversalTypeConverterBlock.""" - nodes = agent.get("nodes", []) - links = agent.get("links", []) - block_map = {b.get("id"): b for b in blocks_info} - node_lookup = {n.get("id"): n for n in nodes} - - def get_property_type(schema: dict, name: str) -> str | None: - if "_#_" in name: - parent, child = name.split("_#_", 1) - parent_schema = schema.get(parent, {}) - if "properties" in parent_schema: - return parent_schema["properties"].get(child, {}).get("type") - return None - return schema.get(name, {}).get("type") - - def are_types_compatible(src: str, sink: str) -> bool: - if {src, sink} <= {"integer", "number"}: - return True - return src == sink - - type_mapping = { - "string": "string", - "text": "string", - "integer": "number", - "number": "number", - "float": "number", - "boolean": "boolean", - "bool": "boolean", - "array": "list", - "list": "list", - "object": "dictionary", - "dict": "dictionary", - "dictionary": "dictionary", - } - - new_links = [] - nodes_to_add = [] - - for link in links: - source_node = node_lookup.get(link.get("source_id")) - sink_node = node_lookup.get(link.get("sink_id")) - - if not source_node or not sink_node: - new_links.append(link) - continue - - source_block = block_map.get(source_node.get("block_id")) - sink_block = block_map.get(sink_node.get("block_id")) - - if not source_block or not sink_block: - new_links.append(link) - continue - - source_outputs = source_block.get("outputSchema", {}).get("properties", {}) - sink_inputs = sink_block.get("inputSchema", {}).get("properties", {}) - - source_type = get_property_type(source_outputs, link.get("source_name", "")) - sink_type = get_property_type(sink_inputs, link.get("sink_name", "")) - - if ( - source_type - and sink_type - and not are_types_compatible(source_type, sink_type) - ): - # Insert type converter - converter_id = str(uuid.uuid4()) - target_type = type_mapping.get(sink_type, sink_type) - - converter_node = { - "id": converter_id, - "block_id": UNIVERSAL_TYPE_CONVERTER_BLOCK_ID, - "input_default": {"type": target_type}, - "metadata": {"position": {"x": 0, "y": 100}}, - } - nodes_to_add.append(converter_node) - - # source -> converter - new_links.append( - { - "id": str(uuid.uuid4()), - "source_id": link["source_id"], - "source_name": link["source_name"], - "sink_id": converter_id, - "sink_name": "value", - "is_static": False, - } - ) - - # converter -> sink - new_links.append( - { - "id": str(uuid.uuid4()), - "source_id": converter_id, - "source_name": "value", - "sink_id": link["sink_id"], - "sink_name": link["sink_name"], - "is_static": False, - } - ) - - logger.debug(f"Inserted type converter: {source_type} -> {target_type}") - else: - new_links.append(link) - - if nodes_to_add: - agent["nodes"] = nodes + nodes_to_add - agent["links"] = new_links - - return agent - - -def apply_all_fixes( - agent: dict[str, Any], blocks_info: list[dict[str, Any]] | None = None -) -> dict[str, Any]: - """Apply all fixes to an agent JSON. - - Args: - agent: Agent JSON dict - blocks_info: Optional list of block info dicts for advanced fixes - - Returns: - Fixed agent JSON - """ - # Basic fixes (no block info needed) - agent = fix_agent_ids(agent) - agent = fix_double_curly_braces(agent) - agent = fix_storevalue_before_condition(agent) - agent = fix_addtolist_blocks(agent) - agent = fix_addtodictionary_blocks(agent) - agent = fix_code_execution_output(agent) - agent = fix_data_sampling_sample_size(agent) - agent = fix_node_x_coordinates(agent) - agent = fix_getcurrentdate_offset(agent) - - # Advanced fixes (require block info) - if blocks_info is None: - blocks_info = get_blocks_info() - - agent = fix_ai_model_parameter(agent, blocks_info) - agent = fix_link_static_properties(agent, blocks_info) - agent = fix_data_type_mismatch(agent, blocks_info) - - return agent diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/prompts.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/prompts.py deleted file mode 100644 index 228bba8c8a..0000000000 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/prompts.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Prompt templates for agent generation.""" - -DECOMPOSITION_PROMPT = """ -You are an expert AutoGPT Workflow Decomposer. Your task is to analyze a user's high-level goal and break it down into a clear, step-by-step plan using the available blocks. - -Each step should represent a distinct, automatable action suitable for execution by an AI automation system. - ---- - -FIRST: Analyze the user's goal and determine: -1) Design-time configuration (fixed settings that won't change per run) -2) Runtime inputs (values the agent's end-user will provide each time it runs) - -For anything that can vary per run (email addresses, names, dates, search terms, etc.): -- DO NOT ask for the actual value -- Instead, define it as an Agent Input with a clear name, type, and description - -Only ask clarifying questions about design-time config that affects how you build the workflow: -- Which external service to use (e.g., "Gmail vs Outlook", "Notion vs Google Docs") -- Required formats or structures (e.g., "CSV, JSON, or PDF output?") -- Business rules that must be hard-coded - -IMPORTANT CLARIFICATIONS POLICY: -- Ask no more than five essential questions -- Do not ask for concrete values that can be provided at runtime as Agent Inputs -- Do not ask for API keys or credentials; the platform handles those directly -- If there is enough information to infer reasonable defaults, prefer to propose defaults - ---- - -GUIDELINES: -1. List each step as a numbered item -2. Describe the action clearly and specify inputs/outputs -3. Ensure steps are in logical, sequential order -4. Mention block names naturally (e.g., "Use GetWeatherByLocationBlock to...") -5. Help the user reach their goal efficiently - ---- - -RULES: -1. OUTPUT FORMAT: Only output either clarifying questions OR step-by-step instructions, not both -2. USE ONLY THE BLOCKS PROVIDED -3. ALL required_input fields must be provided -4. Data types of linked properties must match -5. Write expert-level prompts for AI-related blocks - ---- - -CRITICAL BLOCK RESTRICTIONS: -1. AddToListBlock: Outputs updated list EVERY addition, not after all additions -2. SendEmailBlock: Draft the email for user review; set SMTP config based on email type -3. ConditionBlock: value2 is reference, value1 is contrast -4. CodeExecutionBlock: DO NOT USE - use AI blocks instead -5. ReadCsvBlock: Only use the 'rows' output, not 'row' - ---- - -OUTPUT FORMAT: - -If more information is needed: -```json -{{ - "type": "clarifying_questions", - "questions": [ - {{ - "question": "Which email provider should be used? (Gmail, Outlook, custom SMTP)", - "keyword": "email_provider", - "example": "Gmail" - }} - ] -}} -``` - -If ready to proceed: -```json -{{ - "type": "instructions", - "steps": [ - {{ - "step_number": 1, - "block_name": "AgentShortTextInputBlock", - "description": "Get the URL of the content to analyze.", - "inputs": [{{"name": "name", "value": "URL"}}], - "outputs": [{{"name": "result", "description": "The URL entered by user"}}] - }} - ] -}} -``` - ---- - -AVAILABLE BLOCKS: -{block_summaries} -""" - -GENERATION_PROMPT = """ -You are an expert AI workflow builder. Generate a valid agent JSON from the given instructions. - ---- - -NODES: -Each node must include: -- `id`: Unique UUID v4 (e.g. `a8f5b1e2-c3d4-4e5f-8a9b-0c1d2e3f4a5b`) -- `block_id`: The block identifier (must match an Allowed Block) -- `input_default`: Dict of inputs (can be empty if no static inputs needed) -- `metadata`: Must contain: - - `position`: {{"x": number, "y": number}} - adjacent nodes should differ by 800+ in X - - `customized_name`: Clear name describing this block's purpose in the workflow - ---- - -LINKS: -Each link connects a source node's output to a sink node's input: -- `id`: MUST be UUID v4 (NOT "link-1", "link-2", etc.) -- `source_id`: ID of the source node -- `source_name`: Output field name from the source block -- `sink_id`: ID of the sink node -- `sink_name`: Input field name on the sink block -- `is_static`: true only if source block has static_output: true - -CRITICAL: All IDs must be valid UUID v4 format! - ---- - -AGENT (GRAPH): -Wrap nodes and links in: -- `id`: UUID of the agent -- `name`: Short, generic name (avoid specific company names, URLs) -- `description`: Short, generic description -- `nodes`: List of all nodes -- `links`: List of all links -- `version`: 1 -- `is_active`: true - ---- - -TIPS: -- All required_input fields must be provided via input_default or a valid link -- Ensure consistent source_id and sink_id references -- Avoid dangling links -- Input/output pins must match block schemas -- Do not invent unknown block_ids - ---- - -ALLOWED BLOCKS: -{block_summaries} - ---- - -Generate the complete agent JSON. Output ONLY valid JSON, no explanation. -""" - -PATCH_PROMPT = """ -You are an expert at modifying AutoGPT agent workflows. Given the current agent and a modification request, generate a JSON patch to update the agent. - -CURRENT AGENT: -{current_agent} - -AVAILABLE BLOCKS: -{block_summaries} - ---- - -PATCH FORMAT: -Return a JSON object with the following structure: - -```json -{{ - "type": "patch", - "intent": "Brief description of what the patch does", - "patches": [ - {{ - "type": "modify", - "node_id": "uuid-of-node-to-modify", - "changes": {{ - "input_default": {{"field": "new_value"}}, - "metadata": {{"customized_name": "New Name"}} - }} - }}, - {{ - "type": "add", - "new_nodes": [ - {{ - "id": "new-uuid", - "block_id": "block-uuid", - "input_default": {{}}, - "metadata": {{"position": {{"x": 0, "y": 0}}, "customized_name": "Name"}} - }} - ], - "new_links": [ - {{ - "id": "link-uuid", - "source_id": "source-node-id", - "source_name": "output_field", - "sink_id": "sink-node-id", - "sink_name": "input_field" - }} - ] - }}, - {{ - "type": "remove", - "node_ids": ["uuid-of-node-to-remove"], - "link_ids": ["uuid-of-link-to-remove"] - }} - ] -}} -``` - -If you need more information, return: -```json -{{ - "type": "clarifying_questions", - "questions": [ - {{ - "question": "What specific change do you want?", - "keyword": "change_type", - "example": "Add error handling" - }} - ] -}} -``` - -Generate the minimal patch needed. Output ONLY valid JSON. -""" diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py new file mode 100644 index 0000000000..a4d2f1af15 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py @@ -0,0 +1,269 @@ +"""External Agent Generator service client. + +This module provides a client for communicating with the external Agent Generator +microservice. When AGENTGENERATOR_HOST is configured, the agent generation functions +will delegate to the external service instead of using the built-in LLM-based implementation. +""" + +import logging +from typing import Any + +import httpx + +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) + +_client: httpx.AsyncClient | None = None +_settings: Settings | None = None + + +def _get_settings() -> Settings: + """Get or create settings singleton.""" + global _settings + if _settings is None: + _settings = Settings() + return _settings + + +def is_external_service_configured() -> bool: + """Check if external Agent Generator service is configured.""" + settings = _get_settings() + return bool(settings.config.agentgenerator_host) + + +def _get_base_url() -> str: + """Get the base URL for the external service.""" + settings = _get_settings() + host = settings.config.agentgenerator_host + port = settings.config.agentgenerator_port + return f"http://{host}:{port}" + + +def _get_client() -> httpx.AsyncClient: + """Get or create the HTTP client for the external service.""" + global _client + if _client is None: + settings = _get_settings() + _client = httpx.AsyncClient( + base_url=_get_base_url(), + timeout=httpx.Timeout(settings.config.agentgenerator_timeout), + ) + return _client + + +async def decompose_goal_external( + description: str, context: str = "" +) -> dict[str, Any] | None: + """Call the external service to decompose a goal. + + Args: + description: Natural language goal description + context: Additional context (e.g., answers to previous questions) + + Returns: + Dict with either: + - {"type": "clarifying_questions", "questions": [...]} + - {"type": "instructions", "steps": [...]} + - {"type": "unachievable_goal", ...} + - {"type": "vague_goal", ...} + Or None on error + """ + client = _get_client() + + # Build the request payload + payload: dict[str, Any] = {"description": description} + if context: + # The external service uses user_instruction for additional context + payload["user_instruction"] = context + + try: + response = await client.post("/api/decompose-description", json=payload) + response.raise_for_status() + data = response.json() + + if not data.get("success"): + logger.error(f"External service returned error: {data.get('error')}") + return None + + # Map the response to the expected format + response_type = data.get("type") + if response_type == "instructions": + return {"type": "instructions", "steps": data.get("steps", [])} + elif response_type == "clarifying_questions": + return { + "type": "clarifying_questions", + "questions": data.get("questions", []), + } + elif response_type == "unachievable_goal": + return { + "type": "unachievable_goal", + "reason": data.get("reason"), + "suggested_goal": data.get("suggested_goal"), + } + elif response_type == "vague_goal": + return { + "type": "vague_goal", + "suggested_goal": data.get("suggested_goal"), + } + else: + logger.error( + f"Unknown response type from external service: {response_type}" + ) + return None + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error calling external agent generator: {e}") + return None + except httpx.RequestError as e: + logger.error(f"Request error calling external agent generator: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error calling external agent generator: {e}") + return None + + +async def generate_agent_external( + instructions: dict[str, Any] +) -> dict[str, Any] | None: + """Call the external service to generate an agent from instructions. + + Args: + instructions: Structured instructions from decompose_goal + + Returns: + Agent JSON dict or None on error + """ + client = _get_client() + + try: + response = await client.post( + "/api/generate-agent", json={"instructions": instructions} + ) + response.raise_for_status() + data = response.json() + + if not data.get("success"): + logger.error(f"External service returned error: {data.get('error')}") + return None + + return data.get("agent_json") + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error calling external agent generator: {e}") + return None + except httpx.RequestError as e: + logger.error(f"Request error calling external agent generator: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error calling external agent generator: {e}") + return None + + +async def generate_agent_patch_external( + update_request: str, current_agent: dict[str, Any] +) -> dict[str, Any] | None: + """Call the external service to generate a patch for an existing agent. + + Args: + update_request: Natural language description of changes + current_agent: Current agent JSON + + Returns: + Updated agent JSON, clarifying questions dict, or None on error + """ + client = _get_client() + + try: + response = await client.post( + "/api/update-agent", + json={ + "update_request": update_request, + "current_agent_json": current_agent, + }, + ) + response.raise_for_status() + data = response.json() + + if not data.get("success"): + logger.error(f"External service returned error: {data.get('error')}") + return None + + # Check if it's clarifying questions + if data.get("type") == "clarifying_questions": + return { + "type": "clarifying_questions", + "questions": data.get("questions", []), + } + + # Otherwise return the updated agent JSON + return data.get("agent_json") + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error calling external agent generator: {e}") + return None + except httpx.RequestError as e: + logger.error(f"Request error calling external agent generator: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error calling external agent generator: {e}") + return None + + +async def get_blocks_external() -> list[dict[str, Any]] | None: + """Get available blocks from the external service. + + Returns: + List of block info dicts or None on error + """ + client = _get_client() + + try: + response = await client.get("/api/blocks") + response.raise_for_status() + data = response.json() + + if not data.get("success"): + logger.error("External service returned error getting blocks") + return None + + return data.get("blocks", []) + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error getting blocks from external service: {e}") + return None + except httpx.RequestError as e: + logger.error(f"Request error getting blocks from external service: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error getting blocks from external service: {e}") + return None + + +async def health_check() -> bool: + """Check if the external service is healthy. + + Returns: + True if healthy, False otherwise + """ + if not is_external_service_configured(): + return False + + client = _get_client() + + try: + response = await client.get("/health") + response.raise_for_status() + data = response.json() + return data.get("status") == "healthy" and data.get("blocks_loaded", False) + except Exception as e: + logger.warning(f"External agent generator health check failed: {e}") + return False + + +async def close_client() -> None: + """Close the HTTP client.""" + global _client + if _client is not None: + await _client.aclose() + _client = None diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/utils.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/utils.py deleted file mode 100644 index 9c3c866c7f..0000000000 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/utils.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Utilities for agent generation.""" - -import json -import re -from typing import Any - -from backend.data.block import get_blocks - -# UUID validation regex -UUID_REGEX = re.compile( - r"^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" -) - -# Block IDs for various fixes -STORE_VALUE_BLOCK_ID = "1ff065e9-88e8-4358-9d82-8dc91f622ba9" -CONDITION_BLOCK_ID = "715696a0-e1da-45c8-b209-c2fa9c3b0be6" -ADDTOLIST_BLOCK_ID = "aeb08fc1-2fc1-4141-bc8e-f758f183a822" -ADDTODICTIONARY_BLOCK_ID = "31d1064e-7446-4693-a7d4-65e5ca1180d1" -CREATELIST_BLOCK_ID = "a912d5c7-6e00-4542-b2a9-8034136930e4" -CREATEDICT_BLOCK_ID = "b924ddf4-de4f-4b56-9a85-358930dcbc91" -CODE_EXECUTION_BLOCK_ID = "0b02b072-abe7-11ef-8372-fb5d162dd712" -DATA_SAMPLING_BLOCK_ID = "4a448883-71fa-49cf-91cf-70d793bd7d87" -UNIVERSAL_TYPE_CONVERTER_BLOCK_ID = "95d1b990-ce13-4d88-9737-ba5c2070c97b" -GET_CURRENT_DATE_BLOCK_ID = "b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0b1" - -DOUBLE_CURLY_BRACES_BLOCK_IDS = [ - "44f6c8ad-d75c-4ae1-8209-aad1c0326928", # FillTextTemplateBlock - "6ab085e2-20b3-4055-bc3e-08036e01eca6", - "90f8c45e-e983-4644-aa0b-b4ebe2f531bc", - "363ae599-353e-4804-937e-b2ee3cef3da4", # AgentOutputBlock - "3b191d9f-356f-482d-8238-ba04b6d18381", - "db7d8f02-2f44-4c55-ab7a-eae0941f0c30", - "3a7c4b8d-6e2f-4a5d-b9c1-f8d23c5a9b0e", - "ed1ae7a0-b770-4089-b520-1f0005fad19a", - "a892b8d9-3e4e-4e9c-9c1e-75f8efcf1bfa", - "b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0b1", - "716a67b3-6760-42e7-86dc-18645c6e00fc", - "530cf046-2ce0-4854-ae2c-659db17c7a46", - "ed55ac19-356e-4243-a6cb-bc599e9b716f", - "1f292d4a-41a4-4977-9684-7c8d560b9f91", # LLM blocks - "32a87eab-381e-4dd4-bdb8-4c47151be35a", -] - - -def is_valid_uuid(value: str) -> bool: - """Check if a string is a valid UUID v4.""" - return isinstance(value, str) and UUID_REGEX.match(value) is not None - - -def _compact_schema(schema: dict) -> dict[str, str]: - """Extract compact type info from a JSON schema properties dict. - - Returns a dict of {field_name: type_string} for essential info only. - """ - props = schema.get("properties", {}) - result = {} - - for name, prop in props.items(): - # Skip internal/complex fields - if name.startswith("_"): - continue - - # Get type string - type_str = prop.get("type", "any") - - # Handle anyOf/oneOf (optional types) - if "anyOf" in prop: - types = [t.get("type", "?") for t in prop["anyOf"] if t.get("type")] - type_str = "|".join(types) if types else "any" - elif "allOf" in prop: - type_str = "object" - - # Add array item type if present - if type_str == "array" and "items" in prop: - items = prop["items"] - if isinstance(items, dict): - item_type = items.get("type", "any") - type_str = f"array[{item_type}]" - - result[name] = type_str - - return result - - -def get_block_summaries(include_schemas: bool = True) -> str: - """Generate compact block summaries for prompts. - - Args: - include_schemas: Whether to include input/output type info - - Returns: - Formatted string of block summaries (compact format) - """ - blocks = get_blocks() - summaries = [] - - for block_id, block_cls in blocks.items(): - block = block_cls() - name = block.name - desc = getattr(block, "description", "") or "" - - # Truncate description - if len(desc) > 150: - desc = desc[:147] + "..." - - if not include_schemas: - summaries.append(f"- {name} (id: {block_id}): {desc}") - else: - # Compact format with type info only - inputs = {} - outputs = {} - required = [] - - if hasattr(block, "input_schema"): - try: - schema = block.input_schema.jsonschema() - inputs = _compact_schema(schema) - required = schema.get("required", []) - except Exception: - pass - - if hasattr(block, "output_schema"): - try: - schema = block.output_schema.jsonschema() - outputs = _compact_schema(schema) - except Exception: - pass - - # Build compact line format - # Format: NAME (id): desc | in: {field:type, ...} [required] | out: {field:type} - in_str = ", ".join(f"{k}:{v}" for k, v in inputs.items()) - out_str = ", ".join(f"{k}:{v}" for k, v in outputs.items()) - req_str = f" req=[{','.join(required)}]" if required else "" - - static = " [static]" if getattr(block, "static_output", False) else "" - - line = f"- {name} (id: {block_id}): {desc}" - if in_str: - line += f"\n in: {{{in_str}}}{req_str}" - if out_str: - line += f"\n out: {{{out_str}}}{static}" - - summaries.append(line) - - return "\n".join(summaries) - - -def get_blocks_info() -> list[dict[str, Any]]: - """Get block information with schemas for validation and fixing.""" - blocks = get_blocks() - blocks_info = [] - for block_id, block_cls in blocks.items(): - block = block_cls() - blocks_info.append( - { - "id": block_id, - "name": block.name, - "description": getattr(block, "description", ""), - "categories": getattr(block, "categories", []), - "staticOutput": getattr(block, "static_output", False), - "inputSchema": ( - block.input_schema.jsonschema() - if hasattr(block, "input_schema") - else {} - ), - "outputSchema": ( - block.output_schema.jsonschema() - if hasattr(block, "output_schema") - else {} - ), - } - ) - return blocks_info - - -def parse_json_from_llm(text: str) -> dict[str, Any] | None: - """Extract JSON from LLM response (handles markdown code blocks).""" - if not text: - return None - - # Try fenced code block - match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text, re.IGNORECASE) - if match: - try: - return json.loads(match.group(1).strip()) - except json.JSONDecodeError: - pass - - # Try raw text - try: - return json.loads(text.strip()) - except json.JSONDecodeError: - pass - - # Try finding {...} span - start = text.find("{") - end = text.rfind("}") - if start != -1 and end > start: - try: - return json.loads(text[start : end + 1]) - except json.JSONDecodeError: - pass - - # Try finding [...] span - start = text.find("[") - end = text.rfind("]") - if start != -1 and end > start: - try: - return json.loads(text[start : end + 1]) - except json.JSONDecodeError: - pass - - return None diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/validator.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/validator.py deleted file mode 100644 index c913e92bfd..0000000000 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/validator.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Agent validator - Validates agent structure and connections.""" - -import logging -import re -from typing import Any - -from .utils import get_blocks_info - -logger = logging.getLogger(__name__) - - -class AgentValidator: - """Validator for AutoGPT agents with detailed error reporting.""" - - def __init__(self): - self.errors: list[str] = [] - - def add_error(self, error: str) -> None: - """Add an error message.""" - self.errors.append(error) - - def validate_block_existence( - self, agent: dict[str, Any], blocks_info: list[dict[str, Any]] - ) -> bool: - """Validate all block IDs exist in the blocks library.""" - valid = True - valid_block_ids = {b.get("id") for b in blocks_info if b.get("id")} - - for node in agent.get("nodes", []): - block_id = node.get("block_id") - node_id = node.get("id") - - if not block_id: - self.add_error(f"Node '{node_id}' is missing 'block_id' field.") - valid = False - continue - - if block_id not in valid_block_ids: - self.add_error( - f"Node '{node_id}' references block_id '{block_id}' which does not exist." - ) - valid = False - - return valid - - def validate_link_node_references(self, agent: dict[str, Any]) -> bool: - """Validate all node IDs referenced in links exist.""" - valid = True - valid_node_ids = {n.get("id") for n in agent.get("nodes", []) if n.get("id")} - - for link in agent.get("links", []): - link_id = link.get("id", "Unknown") - source_id = link.get("source_id") - sink_id = link.get("sink_id") - - if not source_id: - self.add_error(f"Link '{link_id}' is missing 'source_id'.") - valid = False - elif source_id not in valid_node_ids: - self.add_error( - f"Link '{link_id}' references non-existent source_id '{source_id}'." - ) - valid = False - - if not sink_id: - self.add_error(f"Link '{link_id}' is missing 'sink_id'.") - valid = False - elif sink_id not in valid_node_ids: - self.add_error( - f"Link '{link_id}' references non-existent sink_id '{sink_id}'." - ) - valid = False - - return valid - - def validate_required_inputs( - self, agent: dict[str, Any], blocks_info: list[dict[str, Any]] - ) -> bool: - """Validate required inputs are provided.""" - valid = True - block_map = {b.get("id"): b for b in blocks_info} - - for node in agent.get("nodes", []): - block_id = node.get("block_id") - block = block_map.get(block_id) - - if not block: - continue - - required_inputs = block.get("inputSchema", {}).get("required", []) - input_defaults = node.get("input_default", {}) - node_id = node.get("id") - - # Get linked inputs - linked_inputs = { - link["sink_name"] - for link in agent.get("links", []) - if link.get("sink_id") == node_id - } - - for req_input in required_inputs: - if ( - req_input not in input_defaults - and req_input not in linked_inputs - and req_input != "credentials" - ): - block_name = block.get("name", "Unknown Block") - self.add_error( - f"Node '{node_id}' ({block_name}) is missing required input '{req_input}'." - ) - valid = False - - return valid - - def validate_data_type_compatibility( - self, agent: dict[str, Any], blocks_info: list[dict[str, Any]] - ) -> bool: - """Validate linked data types are compatible.""" - valid = True - block_map = {b.get("id"): b for b in blocks_info} - node_lookup = {n.get("id"): n for n in agent.get("nodes", [])} - - def get_type(schema: dict, name: str) -> str | None: - if "_#_" in name: - parent, child = name.split("_#_", 1) - parent_schema = schema.get(parent, {}) - if "properties" in parent_schema: - return parent_schema["properties"].get(child, {}).get("type") - return None - return schema.get(name, {}).get("type") - - def are_compatible(src: str, sink: str) -> bool: - if {src, sink} <= {"integer", "number"}: - return True - return src == sink - - for link in agent.get("links", []): - source_node = node_lookup.get(link.get("source_id")) - sink_node = node_lookup.get(link.get("sink_id")) - - if not source_node or not sink_node: - continue - - source_block = block_map.get(source_node.get("block_id")) - sink_block = block_map.get(sink_node.get("block_id")) - - if not source_block or not sink_block: - continue - - source_outputs = source_block.get("outputSchema", {}).get("properties", {}) - sink_inputs = sink_block.get("inputSchema", {}).get("properties", {}) - - source_type = get_type(source_outputs, link.get("source_name", "")) - sink_type = get_type(sink_inputs, link.get("sink_name", "")) - - if source_type and sink_type and not are_compatible(source_type, sink_type): - self.add_error( - f"Type mismatch: {source_block.get('name')} output '{link['source_name']}' " - f"({source_type}) -> {sink_block.get('name')} input '{link['sink_name']}' ({sink_type})." - ) - valid = False - - return valid - - def validate_nested_sink_links( - self, agent: dict[str, Any], blocks_info: list[dict[str, Any]] - ) -> bool: - """Validate nested sink links (with _#_ notation).""" - valid = True - block_map = {b.get("id"): b for b in blocks_info} - node_lookup = {n.get("id"): n for n in agent.get("nodes", [])} - - for link in agent.get("links", []): - sink_name = link.get("sink_name", "") - - if "_#_" in sink_name: - parent, child = sink_name.split("_#_", 1) - - sink_node = node_lookup.get(link.get("sink_id")) - if not sink_node: - continue - - block = block_map.get(sink_node.get("block_id")) - if not block: - continue - - input_props = block.get("inputSchema", {}).get("properties", {}) - parent_schema = input_props.get(parent) - - if not parent_schema: - self.add_error( - f"Invalid nested link '{sink_name}': parent '{parent}' not found." - ) - valid = False - continue - - if not parent_schema.get("additionalProperties"): - if not ( - isinstance(parent_schema, dict) - and "properties" in parent_schema - and child in parent_schema.get("properties", {}) - ): - self.add_error( - f"Invalid nested link '{sink_name}': child '{child}' not found in '{parent}'." - ) - valid = False - - return valid - - def validate_prompt_spaces(self, agent: dict[str, Any]) -> bool: - """Validate prompts don't have spaces in template variables.""" - valid = True - - for node in agent.get("nodes", []): - input_default = node.get("input_default", {}) - prompt = input_default.get("prompt", "") - - if not isinstance(prompt, str): - continue - - # Find {{...}} with spaces - matches = re.finditer(r"\{\{([^}]+)\}\}", prompt) - for match in matches: - content = match.group(1) - if " " in content: - self.add_error( - f"Node '{node.get('id')}' has spaces in template variable: " - f"'{{{{{content}}}}}' should be '{{{{{content.replace(' ', '_')}}}}}'." - ) - valid = False - - return valid - - def validate( - self, agent: dict[str, Any], blocks_info: list[dict[str, Any]] | None = None - ) -> tuple[bool, str | None]: - """Run all validations. - - Returns: - Tuple of (is_valid, error_message) - """ - self.errors = [] - - if blocks_info is None: - blocks_info = get_blocks_info() - - checks = [ - self.validate_block_existence(agent, blocks_info), - self.validate_link_node_references(agent), - self.validate_required_inputs(agent, blocks_info), - self.validate_data_type_compatibility(agent, blocks_info), - self.validate_nested_sink_links(agent, blocks_info), - self.validate_prompt_spaces(agent), - ] - - all_passed = all(checks) - - if all_passed: - logger.info("Agent validation successful") - return True, None - - error_message = "Agent validation failed:\n" - for i, error in enumerate(self.errors, 1): - error_message += f"{i}. {error}\n" - - logger.warning(f"Agent validation failed with {len(self.errors)} errors") - return False, error_message - - -def validate_agent( - agent: dict[str, Any], blocks_info: list[dict[str, Any]] | None = None -) -> tuple[bool, str | None]: - """Convenience function to validate an agent. - - Returns: - Tuple of (is_valid, error_message) - """ - validator = AgentValidator() - return validator.validate(agent, blocks_info) diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py index 26c980c6c5..5a3c44fb94 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py @@ -8,12 +8,10 @@ from langfuse import observe from backend.api.features.chat.model import ChatSession from .agent_generator import ( - apply_all_fixes, + AgentGeneratorNotConfiguredError, decompose_goal, generate_agent, - get_blocks_info, save_agent_to_library, - validate_agent, ) from .base import BaseTool from .models import ( @@ -27,9 +25,6 @@ from .models import ( logger = logging.getLogger(__name__) -# Maximum retries for agent generation with validation feedback -MAX_GENERATION_RETRIES = 2 - class CreateAgentTool(BaseTool): """Tool for creating agents from natural language descriptions.""" @@ -91,9 +86,8 @@ class CreateAgentTool(BaseTool): Flow: 1. Decompose the description into steps (may return clarifying questions) - 2. Generate agent JSON from the steps - 3. Apply fixes to correct common LLM errors - 4. Preview or save based on the save parameter + 2. Generate agent JSON (external service handles fixing and validation) + 3. Preview or save based on the save parameter """ description = kwargs.get("description", "").strip() context = kwargs.get("context", "") @@ -110,11 +104,13 @@ class CreateAgentTool(BaseTool): # Step 1: Decompose goal into steps try: decomposition_result = await decompose_goal(description, context) - except ValueError as e: - # Handle missing API key or configuration errors + except AgentGeneratorNotConfiguredError: return ErrorResponse( - message=f"Agent generation is not configured: {str(e)}", - error="configuration_error", + message=( + "Agent generation is not available. " + "The Agent Generator service is not configured." + ), + error="service_not_configured", session_id=session_id, ) @@ -171,72 +167,32 @@ class CreateAgentTool(BaseTool): session_id=session_id, ) - # Step 2: Generate agent JSON with retry on validation failure - blocks_info = get_blocks_info() - agent_json = None - validation_errors = None - - for attempt in range(MAX_GENERATION_RETRIES + 1): - # Generate agent (include validation errors from previous attempt) - if attempt == 0: - agent_json = await generate_agent(decomposition_result) - else: - # Retry with validation error feedback - logger.info( - f"Retry {attempt}/{MAX_GENERATION_RETRIES} with validation feedback" - ) - retry_instructions = { - **decomposition_result, - "previous_errors": validation_errors, - "retry_instructions": ( - "The previous generation had validation errors. " - "Please fix these issues in the new generation:\n" - f"{validation_errors}" - ), - } - agent_json = await generate_agent(retry_instructions) - - if agent_json is None: - if attempt == MAX_GENERATION_RETRIES: - return ErrorResponse( - message="Failed to generate the agent. Please try again.", - error="Generation failed", - session_id=session_id, - ) - continue - - # Step 3: Apply fixes to correct common errors - agent_json = apply_all_fixes(agent_json, blocks_info) - - # Step 4: Validate the agent - is_valid, validation_errors = validate_agent(agent_json, blocks_info) - - if is_valid: - logger.info(f"Agent generated successfully on attempt {attempt + 1}") - break - - logger.warning( - f"Validation failed on attempt {attempt + 1}: {validation_errors}" + # Step 2: Generate agent JSON (external service handles fixing and validation) + try: + agent_json = await generate_agent(decomposition_result) + except AgentGeneratorNotConfiguredError: + return ErrorResponse( + message=( + "Agent generation is not available. " + "The Agent Generator service is not configured." + ), + error="service_not_configured", + session_id=session_id, ) - if attempt == MAX_GENERATION_RETRIES: - # Return error with validation details - return ErrorResponse( - message=( - f"Generated agent has validation errors after {MAX_GENERATION_RETRIES + 1} attempts. " - f"Please try rephrasing your request or simplify the workflow." - ), - error="validation_failed", - details={"validation_errors": validation_errors}, - session_id=session_id, - ) + if agent_json is None: + return ErrorResponse( + message="Failed to generate the agent. Please try again.", + error="Generation failed", + session_id=session_id, + ) agent_name = agent_json.get("name", "Generated Agent") agent_description = agent_json.get("description", "") node_count = len(agent_json.get("nodes", [])) link_count = len(agent_json.get("links", [])) - # Step 4: Preview or save + # Step 3: Preview or save if not save: return AgentPreviewResponse( message=( diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py index a50a89c5c7..777c39a254 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py @@ -8,13 +8,10 @@ from langfuse import observe from backend.api.features.chat.model import ChatSession from .agent_generator import ( - apply_agent_patch, - apply_all_fixes, + AgentGeneratorNotConfiguredError, generate_agent_patch, get_agent_as_json, - get_blocks_info, save_agent_to_library, - validate_agent, ) from .base import BaseTool from .models import ( @@ -28,9 +25,6 @@ from .models import ( logger = logging.getLogger(__name__) -# Maximum retries for patch generation with validation feedback -MAX_GENERATION_RETRIES = 2 - class EditAgentTool(BaseTool): """Tool for editing existing agents using natural language.""" @@ -43,7 +37,7 @@ class EditAgentTool(BaseTool): def description(self) -> str: return ( "Edit an existing agent from the user's library using natural language. " - "Generates a patch to update the agent while preserving unchanged parts." + "Generates updates to the agent while preserving unchanged parts." ) @property @@ -98,9 +92,8 @@ class EditAgentTool(BaseTool): Flow: 1. Fetch the current agent - 2. Generate a patch based on the requested changes - 3. Apply the patch to create an updated agent - 4. Preview or save based on the save parameter + 2. Generate updated agent (external service handles fixing and validation) + 3. Preview or save based on the save parameter """ agent_id = kwargs.get("agent_id", "").strip() changes = kwargs.get("changes", "").strip() @@ -137,121 +130,58 @@ class EditAgentTool(BaseTool): if context: update_request = f"{changes}\n\nAdditional context:\n{context}" - # Step 2: Generate patch with retry on validation failure - blocks_info = get_blocks_info() - updated_agent = None - validation_errors = None - intent = "Applied requested changes" - - for attempt in range(MAX_GENERATION_RETRIES + 1): - # Generate patch (include validation errors from previous attempt) - try: - if attempt == 0: - patch_result = await generate_agent_patch( - update_request, current_agent - ) - else: - # Retry with validation error feedback - logger.info( - f"Retry {attempt}/{MAX_GENERATION_RETRIES} with validation feedback" - ) - retry_request = ( - f"{update_request}\n\n" - f"IMPORTANT: The previous edit had validation errors. " - f"Please fix these issues:\n{validation_errors}" - ) - patch_result = await generate_agent_patch( - retry_request, current_agent - ) - except ValueError as e: - # Handle missing API key or configuration errors - return ErrorResponse( - message=f"Agent generation is not configured: {str(e)}", - error="configuration_error", - session_id=session_id, - ) - - if patch_result is None: - if attempt == MAX_GENERATION_RETRIES: - return ErrorResponse( - message="Failed to generate changes. Please try rephrasing.", - error="Patch generation failed", - session_id=session_id, - ) - continue - - # Check if LLM returned clarifying questions - if patch_result.get("type") == "clarifying_questions": - questions = patch_result.get("questions", []) - return ClarificationNeededResponse( - message=( - "I need some more information about the changes. " - "Please answer the following questions:" - ), - questions=[ - ClarifyingQuestion( - question=q.get("question", ""), - keyword=q.get("keyword", ""), - example=q.get("example"), - ) - for q in questions - ], - session_id=session_id, - ) - - # Step 3: Apply patch and fixes - try: - updated_agent = apply_agent_patch(current_agent, patch_result) - updated_agent = apply_all_fixes(updated_agent, blocks_info) - except Exception as e: - if attempt == MAX_GENERATION_RETRIES: - return ErrorResponse( - message=f"Failed to apply changes: {str(e)}", - error="patch_apply_failed", - details={"exception": str(e)}, - session_id=session_id, - ) - validation_errors = str(e) - continue - - # Step 4: Validate the updated agent - is_valid, validation_errors = validate_agent(updated_agent, blocks_info) - - if is_valid: - logger.info(f"Agent edited successfully on attempt {attempt + 1}") - intent = patch_result.get("intent", "Applied requested changes") - break - - logger.warning( - f"Validation failed on attempt {attempt + 1}: {validation_errors}" + # Step 2: Generate updated agent (external service handles fixing and validation) + try: + result = await generate_agent_patch(update_request, current_agent) + except AgentGeneratorNotConfiguredError: + return ErrorResponse( + message=( + "Agent editing is not available. " + "The Agent Generator service is not configured." + ), + error="service_not_configured", + session_id=session_id, ) - if attempt == MAX_GENERATION_RETRIES: - # Return error with validation details - return ErrorResponse( - message=( - f"Updated agent has validation errors after " - f"{MAX_GENERATION_RETRIES + 1} attempts. " - f"Please try rephrasing your request or simplify the changes." - ), - error="validation_failed", - details={"validation_errors": validation_errors}, - session_id=session_id, - ) + if result is None: + return ErrorResponse( + message="Failed to generate changes. Please try rephrasing.", + error="Update generation failed", + session_id=session_id, + ) - # At this point, updated_agent is guaranteed to be set (we return on all failure paths) - assert updated_agent is not None + # Check if LLM returned clarifying questions + if result.get("type") == "clarifying_questions": + questions = result.get("questions", []) + return ClarificationNeededResponse( + message=( + "I need some more information about the changes. " + "Please answer the following questions:" + ), + questions=[ + ClarifyingQuestion( + question=q.get("question", ""), + keyword=q.get("keyword", ""), + example=q.get("example"), + ) + for q in questions + ], + session_id=session_id, + ) + + # Result is the updated agent JSON + updated_agent = result agent_name = updated_agent.get("name", "Updated Agent") agent_description = updated_agent.get("description", "") node_count = len(updated_agent.get("nodes", [])) link_count = len(updated_agent.get("links", [])) - # Step 5: Preview or save + # Step 3: Preview or save if not save: return AgentPreviewResponse( message=( - f"I've updated the agent. Changes: {intent}. " + f"I've updated the agent. " f"The agent now has {node_count} blocks. " f"Review it and call edit_agent with save=true to save the changes." ), @@ -277,10 +207,7 @@ class EditAgentTool(BaseTool): ) return AgentSavedResponse( - message=( - f"Updated agent '{created_graph.name}' has been saved to your library! " - f"Changes: {intent}" - ), + message=f"Updated agent '{created_graph.name}' has been saved to your library!", agent_id=created_graph.id, agent_name=created_graph.name, library_agent_id=library_agent.id, diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 4448aeb77e..2972fc07c2 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -350,6 +350,19 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="Whether to mark failed scans as clean or not", ) + agentgenerator_host: str = Field( + default="", + description="The host for the Agent Generator service (empty to use built-in)", + ) + agentgenerator_port: int = Field( + default=8000, + description="The port for the Agent Generator service", + ) + agentgenerator_timeout: int = Field( + default=120, + description="The timeout in seconds for Agent Generator service requests", + ) + enable_example_blocks: bool = Field( default=False, description="Whether to enable example blocks in production", diff --git a/autogpt_platform/backend/test/agent_generator/__init__.py b/autogpt_platform/backend/test/agent_generator/__init__.py new file mode 100644 index 0000000000..8fcde1fa0f --- /dev/null +++ b/autogpt_platform/backend/test/agent_generator/__init__.py @@ -0,0 +1 @@ +"""Tests for agent generator module.""" diff --git a/autogpt_platform/backend/test/agent_generator/test_core_integration.py b/autogpt_platform/backend/test/agent_generator/test_core_integration.py new file mode 100644 index 0000000000..bdcc24ba79 --- /dev/null +++ b/autogpt_platform/backend/test/agent_generator/test_core_integration.py @@ -0,0 +1,273 @@ +""" +Tests for the Agent Generator core module. + +This test suite verifies that the core functions correctly delegate to +the external Agent Generator service. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from backend.api.features.chat.tools.agent_generator import core +from backend.api.features.chat.tools.agent_generator.core import ( + AgentGeneratorNotConfiguredError, +) + + +class TestServiceNotConfigured: + """Test that functions raise AgentGeneratorNotConfiguredError when service is not configured.""" + + @pytest.mark.asyncio + async def test_decompose_goal_raises_when_not_configured(self): + """Test that decompose_goal raises error when service not configured.""" + with patch.object(core, "is_external_service_configured", return_value=False): + with pytest.raises(AgentGeneratorNotConfiguredError): + await core.decompose_goal("Build a chatbot") + + @pytest.mark.asyncio + async def test_generate_agent_raises_when_not_configured(self): + """Test that generate_agent raises error when service not configured.""" + with patch.object(core, "is_external_service_configured", return_value=False): + with pytest.raises(AgentGeneratorNotConfiguredError): + await core.generate_agent({"steps": []}) + + @pytest.mark.asyncio + async def test_generate_agent_patch_raises_when_not_configured(self): + """Test that generate_agent_patch raises error when service not configured.""" + with patch.object(core, "is_external_service_configured", return_value=False): + with pytest.raises(AgentGeneratorNotConfiguredError): + await core.generate_agent_patch("Add a node", {"nodes": []}) + + +class TestDecomposeGoal: + """Test decompose_goal function service delegation.""" + + @pytest.mark.asyncio + async def test_calls_external_service(self): + """Test that decompose_goal calls the external service.""" + expected_result = {"type": "instructions", "steps": ["Step 1"]} + + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "decompose_goal_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = expected_result + + result = await core.decompose_goal("Build a chatbot") + + mock_external.assert_called_once_with("Build a chatbot", "") + assert result == expected_result + + @pytest.mark.asyncio + async def test_passes_context_to_external_service(self): + """Test that decompose_goal passes context to external service.""" + expected_result = {"type": "instructions", "steps": ["Step 1"]} + + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "decompose_goal_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = expected_result + + await core.decompose_goal("Build a chatbot", "Use Python") + + mock_external.assert_called_once_with("Build a chatbot", "Use Python") + + @pytest.mark.asyncio + async def test_returns_none_on_service_failure(self): + """Test that decompose_goal returns None when external service fails.""" + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "decompose_goal_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = None + + result = await core.decompose_goal("Build a chatbot") + + assert result is None + + +class TestGenerateAgent: + """Test generate_agent function service delegation.""" + + @pytest.mark.asyncio + async def test_calls_external_service(self): + """Test that generate_agent calls the external service.""" + expected_result = {"name": "Test Agent", "nodes": [], "links": []} + + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "generate_agent_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = expected_result + + instructions = {"type": "instructions", "steps": ["Step 1"]} + result = await core.generate_agent(instructions) + + mock_external.assert_called_once_with(instructions) + # Result should have id, version, is_active added if not present + assert result is not None + assert result["name"] == "Test Agent" + assert "id" in result + assert result["version"] == 1 + assert result["is_active"] is True + + @pytest.mark.asyncio + async def test_preserves_existing_id_and_version(self): + """Test that external service result preserves existing id and version.""" + expected_result = { + "id": "existing-id", + "version": 3, + "is_active": False, + "name": "Test Agent", + } + + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "generate_agent_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = expected_result.copy() + + result = await core.generate_agent({"steps": []}) + + assert result is not None + assert result["id"] == "existing-id" + assert result["version"] == 3 + assert result["is_active"] is False + + @pytest.mark.asyncio + async def test_returns_none_when_external_service_fails(self): + """Test that generate_agent returns None when external service fails.""" + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "generate_agent_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = None + + result = await core.generate_agent({"steps": []}) + + assert result is None + + +class TestGenerateAgentPatch: + """Test generate_agent_patch function service delegation.""" + + @pytest.mark.asyncio + async def test_calls_external_service(self): + """Test that generate_agent_patch calls the external service.""" + expected_result = {"name": "Updated Agent", "nodes": [], "links": []} + + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "generate_agent_patch_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = expected_result + + current_agent = {"nodes": [], "links": []} + result = await core.generate_agent_patch("Add a node", current_agent) + + mock_external.assert_called_once_with("Add a node", current_agent) + assert result == expected_result + + @pytest.mark.asyncio + async def test_returns_clarifying_questions(self): + """Test that generate_agent_patch returns clarifying questions.""" + expected_result = { + "type": "clarifying_questions", + "questions": [{"question": "What type of node?"}], + } + + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "generate_agent_patch_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = expected_result + + result = await core.generate_agent_patch("Add a node", {"nodes": []}) + + assert result == expected_result + + @pytest.mark.asyncio + async def test_returns_none_when_external_service_fails(self): + """Test that generate_agent_patch returns None when service fails.""" + with patch.object( + core, "is_external_service_configured", return_value=True + ), patch.object( + core, "generate_agent_patch_external", new_callable=AsyncMock + ) as mock_external: + mock_external.return_value = None + + result = await core.generate_agent_patch("Add a node", {"nodes": []}) + + assert result is None + + +class TestJsonToGraph: + """Test json_to_graph function.""" + + def test_converts_agent_json_to_graph(self): + """Test conversion of agent JSON to Graph model.""" + agent_json = { + "id": "test-id", + "version": 2, + "is_active": True, + "name": "Test Agent", + "description": "A test agent", + "nodes": [ + { + "id": "node1", + "block_id": "block1", + "input_default": {"key": "value"}, + "metadata": {"x": 100}, + } + ], + "links": [ + { + "id": "link1", + "source_id": "node1", + "sink_id": "output", + "source_name": "result", + "sink_name": "input", + "is_static": False, + } + ], + } + + graph = core.json_to_graph(agent_json) + + assert graph.id == "test-id" + assert graph.version == 2 + assert graph.is_active is True + assert graph.name == "Test Agent" + assert graph.description == "A test agent" + assert len(graph.nodes) == 1 + assert graph.nodes[0].id == "node1" + assert graph.nodes[0].block_id == "block1" + assert len(graph.links) == 1 + assert graph.links[0].source_id == "node1" + + def test_generates_ids_if_missing(self): + """Test that missing IDs are generated.""" + agent_json = { + "name": "Test Agent", + "nodes": [{"block_id": "block1"}], + "links": [], + } + + graph = core.json_to_graph(agent_json) + + assert graph.id is not None + assert graph.nodes[0].id is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/autogpt_platform/backend/test/agent_generator/test_service.py b/autogpt_platform/backend/test/agent_generator/test_service.py new file mode 100644 index 0000000000..81ff794532 --- /dev/null +++ b/autogpt_platform/backend/test/agent_generator/test_service.py @@ -0,0 +1,422 @@ +""" +Tests for the Agent Generator external service client. + +This test suite verifies the external Agent Generator service integration, +including service detection, API calls, and error handling. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from backend.api.features.chat.tools.agent_generator import service + + +class TestServiceConfiguration: + """Test service configuration detection.""" + + def setup_method(self): + """Reset settings singleton before each test.""" + service._settings = None + service._client = None + + def test_external_service_not_configured_when_host_empty(self): + """Test that external service is not configured when host is empty.""" + mock_settings = MagicMock() + mock_settings.config.agentgenerator_host = "" + + with patch.object(service, "_get_settings", return_value=mock_settings): + assert service.is_external_service_configured() is False + + def test_external_service_configured_when_host_set(self): + """Test that external service is configured when host is set.""" + mock_settings = MagicMock() + mock_settings.config.agentgenerator_host = "agent-generator.local" + + with patch.object(service, "_get_settings", return_value=mock_settings): + assert service.is_external_service_configured() is True + + def test_get_base_url(self): + """Test base URL construction.""" + mock_settings = MagicMock() + mock_settings.config.agentgenerator_host = "agent-generator.local" + mock_settings.config.agentgenerator_port = 8000 + + with patch.object(service, "_get_settings", return_value=mock_settings): + url = service._get_base_url() + assert url == "http://agent-generator.local:8000" + + +class TestDecomposeGoalExternal: + """Test decompose_goal_external function.""" + + def setup_method(self): + """Reset client singleton before each test.""" + service._settings = None + service._client = None + + @pytest.mark.asyncio + async def test_decompose_goal_returns_instructions(self): + """Test successful decomposition returning instructions.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "type": "instructions", + "steps": ["Step 1", "Step 2"], + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.decompose_goal_external("Build a chatbot") + + assert result == {"type": "instructions", "steps": ["Step 1", "Step 2"]} + mock_client.post.assert_called_once_with( + "/api/decompose-description", json={"description": "Build a chatbot"} + ) + + @pytest.mark.asyncio + async def test_decompose_goal_returns_clarifying_questions(self): + """Test decomposition returning clarifying questions.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "type": "clarifying_questions", + "questions": ["What platform?", "What language?"], + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.decompose_goal_external("Build something") + + assert result == { + "type": "clarifying_questions", + "questions": ["What platform?", "What language?"], + } + + @pytest.mark.asyncio + async def test_decompose_goal_with_context(self): + """Test decomposition with additional context.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "type": "instructions", + "steps": ["Step 1"], + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(service, "_get_client", return_value=mock_client): + await service.decompose_goal_external( + "Build a chatbot", context="Use Python" + ) + + mock_client.post.assert_called_once_with( + "/api/decompose-description", + json={"description": "Build a chatbot", "user_instruction": "Use Python"}, + ) + + @pytest.mark.asyncio + async def test_decompose_goal_returns_unachievable_goal(self): + """Test decomposition returning unachievable goal response.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "type": "unachievable_goal", + "reason": "Cannot do X", + "suggested_goal": "Try Y instead", + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.decompose_goal_external("Do something impossible") + + assert result == { + "type": "unachievable_goal", + "reason": "Cannot do X", + "suggested_goal": "Try Y instead", + } + + @pytest.mark.asyncio + async def test_decompose_goal_handles_http_error(self): + """Test decomposition handles HTTP errors gracefully.""" + mock_client = AsyncMock() + mock_client.post.side_effect = httpx.HTTPStatusError( + "Server error", request=MagicMock(), response=MagicMock() + ) + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.decompose_goal_external("Build a chatbot") + + assert result is None + + @pytest.mark.asyncio + async def test_decompose_goal_handles_request_error(self): + """Test decomposition handles request errors gracefully.""" + mock_client = AsyncMock() + mock_client.post.side_effect = httpx.RequestError("Connection failed") + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.decompose_goal_external("Build a chatbot") + + assert result is None + + @pytest.mark.asyncio + async def test_decompose_goal_handles_service_error(self): + """Test decomposition handles service returning error.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "success": False, + "error": "Internal error", + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.decompose_goal_external("Build a chatbot") + + assert result is None + + +class TestGenerateAgentExternal: + """Test generate_agent_external function.""" + + def setup_method(self): + """Reset client singleton before each test.""" + service._settings = None + service._client = None + + @pytest.mark.asyncio + async def test_generate_agent_success(self): + """Test successful agent generation.""" + agent_json = { + "name": "Test Agent", + "nodes": [], + "links": [], + } + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "agent_json": agent_json, + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + instructions = {"type": "instructions", "steps": ["Step 1"]} + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.generate_agent_external(instructions) + + assert result == agent_json + mock_client.post.assert_called_once_with( + "/api/generate-agent", json={"instructions": instructions} + ) + + @pytest.mark.asyncio + async def test_generate_agent_handles_error(self): + """Test agent generation handles errors gracefully.""" + mock_client = AsyncMock() + mock_client.post.side_effect = httpx.RequestError("Connection failed") + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.generate_agent_external({"steps": []}) + + assert result is None + + +class TestGenerateAgentPatchExternal: + """Test generate_agent_patch_external function.""" + + def setup_method(self): + """Reset client singleton before each test.""" + service._settings = None + service._client = None + + @pytest.mark.asyncio + async def test_generate_patch_returns_updated_agent(self): + """Test successful patch generation returning updated agent.""" + updated_agent = { + "name": "Updated Agent", + "nodes": [{"id": "1", "block_id": "test"}], + "links": [], + } + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "agent_json": updated_agent, + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + current_agent = {"name": "Old Agent", "nodes": [], "links": []} + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.generate_agent_patch_external( + "Add a new node", current_agent + ) + + assert result == updated_agent + mock_client.post.assert_called_once_with( + "/api/update-agent", + json={ + "update_request": "Add a new node", + "current_agent_json": current_agent, + }, + ) + + @pytest.mark.asyncio + async def test_generate_patch_returns_clarifying_questions(self): + """Test patch generation returning clarifying questions.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "type": "clarifying_questions", + "questions": ["What type of node?"], + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.generate_agent_patch_external( + "Add something", {"nodes": []} + ) + + assert result == { + "type": "clarifying_questions", + "questions": ["What type of node?"], + } + + +class TestHealthCheck: + """Test health_check function.""" + + def setup_method(self): + """Reset singletons before each test.""" + service._settings = None + service._client = None + + @pytest.mark.asyncio + async def test_health_check_returns_false_when_not_configured(self): + """Test health check returns False when service not configured.""" + with patch.object( + service, "is_external_service_configured", return_value=False + ): + result = await service.health_check() + assert result is False + + @pytest.mark.asyncio + async def test_health_check_returns_true_when_healthy(self): + """Test health check returns True when service is healthy.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "status": "healthy", + "blocks_loaded": True, + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + with patch.object(service, "is_external_service_configured", return_value=True): + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.health_check() + + assert result is True + mock_client.get.assert_called_once_with("/health") + + @pytest.mark.asyncio + async def test_health_check_returns_false_when_not_healthy(self): + """Test health check returns False when service is not healthy.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "status": "unhealthy", + "blocks_loaded": False, + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + with patch.object(service, "is_external_service_configured", return_value=True): + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.health_check() + + assert result is False + + @pytest.mark.asyncio + async def test_health_check_returns_false_on_error(self): + """Test health check returns False on connection error.""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.RequestError("Connection failed") + + with patch.object(service, "is_external_service_configured", return_value=True): + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.health_check() + + assert result is False + + +class TestGetBlocksExternal: + """Test get_blocks_external function.""" + + def setup_method(self): + """Reset client singleton before each test.""" + service._settings = None + service._client = None + + @pytest.mark.asyncio + async def test_get_blocks_success(self): + """Test successful blocks retrieval.""" + blocks = [ + {"id": "block1", "name": "Block 1"}, + {"id": "block2", "name": "Block 2"}, + ] + mock_response = MagicMock() + mock_response.json.return_value = { + "success": True, + "blocks": blocks, + } + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.get_blocks_external() + + assert result == blocks + mock_client.get.assert_called_once_with("/api/blocks") + + @pytest.mark.asyncio + async def test_get_blocks_handles_error(self): + """Test blocks retrieval handles errors gracefully.""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.RequestError("Connection failed") + + with patch.object(service, "_get_client", return_value=mock_client): + result = await service.get_blocks_external() + + assert result is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From cfb7dc5aca31b675dd1b634fcd2bc7df7b328b2b Mon Sep 17 00:00:00 2001 From: Swifty Date: Mon, 26 Jan 2026 13:26:15 +0100 Subject: [PATCH 08/36] feat(backend): Add PostHog analytics and OpenRouter tracing to chat system (#11828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds analytics tracking to the chat copilot system for better observability of user interactions and agent operations. ### Changes šŸ—ļø **PostHog Analytics Integration:** - Added `posthog` dependency (v7.6.0) to track chat events - Created new tracking module (`backend/api/features/chat/tracking.py`) with events: - `chat_message_sent` - When a user sends a message - `chat_tool_called` - When a tool is called (includes tool name) - `chat_agent_run_success` - When an agent runs successfully - `chat_agent_scheduled` - When an agent is scheduled - `chat_trigger_setup` - When a trigger is set up - Added PostHog configuration to settings: - `POSTHOG_API_KEY` - API key for PostHog - `POSTHOG_HOST` - PostHog host URL (defaults to `https://us.i.posthog.com`) **OpenRouter Tracing:** - Added `user` and `session_id` fields to chat completion API calls for OpenRouter tracing - Added `posthogDistinctId` and `posthogProperties` (with environment) to API calls **Files Changed:** - `backend/api/features/chat/tracking.py` - New PostHog tracking module - `backend/api/features/chat/service.py` - Added user message tracking and OpenRouter tracing - `backend/api/features/chat/tools/__init__.py` - Added tool call tracking - `backend/api/features/chat/tools/run_agent.py` - Added agent run/schedule tracking - `backend/util/settings.py` - Added PostHog configuration fields - `pyproject.toml` - Added posthog dependency ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verified code passes linting and formatting - [x] Verified PostHog client initializes correctly when API key is provided - [x] Verified tracking is gracefully skipped when PostHog is not configured #### For configuration changes: - [ ] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) **New environment variables (optional):** - `POSTHOG_API_KEY` - PostHog project API key - `POSTHOG_HOST` - PostHog host URL (optional, defaults to US cloud) --- autogpt_platform/backend/.env.default | 5 + .../backend/api/features/chat/service.py | 51 +++- .../api/features/chat/tools/__init__.py | 17 ++ .../api/features/chat/tools/run_agent.py | 26 ++ .../backend/api/features/chat/tracking.py | 250 ++++++++++++++++++ .../backend/backend/util/settings.py | 6 + autogpt_platform/backend/poetry.lock | 12 +- autogpt_platform/backend/pyproject.toml | 1 + autogpt_platform/frontend/.env.default | 4 + autogpt_platform/frontend/package.json | 4 +- autogpt_platform/frontend/pnpm-lock.yaml | 246 +++++++++++++++++ .../frontend/src/app/providers.tsx | 36 ++- .../providers/posthog/posthog-provider.tsx | 64 +++++ 13 files changed, 701 insertions(+), 21 deletions(-) create mode 100644 autogpt_platform/backend/backend/api/features/chat/tracking.py create mode 100644 autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx diff --git a/autogpt_platform/backend/.env.default b/autogpt_platform/backend/.env.default index 49689d7ad6..b393f13017 100644 --- a/autogpt_platform/backend/.env.default +++ b/autogpt_platform/backend/.env.default @@ -178,5 +178,10 @@ AYRSHARE_JWT_KEY= SMARTLEAD_API_KEY= ZEROBOUNCE_API_KEY= +# PostHog Analytics +# Get API key from https://posthog.com - Project Settings > Project API Key +POSTHOG_API_KEY= +POSTHOG_HOST=https://eu.i.posthog.com + # Other Services AUTOMOD_API_KEY= diff --git a/autogpt_platform/backend/backend/api/features/chat/service.py b/autogpt_platform/backend/backend/api/features/chat/service.py index 3daf378f65..43fb7b35a7 100644 --- a/autogpt_platform/backend/backend/api/features/chat/service.py +++ b/autogpt_platform/backend/backend/api/features/chat/service.py @@ -48,6 +48,7 @@ from .response_model import ( StreamUsage, ) from .tools import execute_tool, tools +from .tracking import track_user_message logger = logging.getLogger(__name__) @@ -103,16 +104,33 @@ async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]: return compiled, understanding -async def _generate_session_title(message: str) -> str | None: +async def _generate_session_title( + message: str, + user_id: str | None = None, + session_id: str | None = None, +) -> str | None: """Generate a concise title for a chat session based on the first message. Args: message: The first user message in the session + user_id: User ID for OpenRouter tracing (optional) + session_id: Session ID for OpenRouter tracing (optional) Returns: A short title (3-6 words) or None if generation fails """ try: + # Build extra_body for OpenRouter tracing and PostHog analytics + extra_body: dict[str, Any] = {} + if user_id: + extra_body["user"] = user_id[:128] # OpenRouter limit + extra_body["posthogDistinctId"] = user_id + if session_id: + extra_body["session_id"] = session_id[:128] # OpenRouter limit + extra_body["posthogProperties"] = { + "environment": settings.config.app_env.value, + } + response = await client.chat.completions.create( model=config.title_model, messages=[ @@ -127,6 +145,7 @@ async def _generate_session_title(message: str) -> str | None: {"role": "user", "content": message[:500]}, # Limit input length ], max_tokens=20, + extra_body=extra_body, ) title = response.choices[0].message.content if title: @@ -237,6 +256,14 @@ async def stream_chat_completion( f"new message_count={len(session.messages)}" ) + # Track user message in PostHog + if is_user_message: + track_user_message( + user_id=user_id, + session_id=session_id, + message_length=len(message), + ) + logger.info( f"Upserting session: {session.session_id} with user id {session.user_id}, " f"message_count={len(session.messages)}" @@ -256,10 +283,15 @@ async def stream_chat_completion( # stale data issues when the main flow modifies the session captured_session_id = session_id captured_message = message + captured_user_id = user_id async def _update_title(): try: - title = await _generate_session_title(captured_message) + title = await _generate_session_title( + captured_message, + user_id=captured_user_id, + session_id=captured_session_id, + ) if title: # Use dedicated title update function that doesn't # touch messages, avoiding race conditions @@ -698,6 +730,20 @@ async def _stream_chat_chunks( f"{f' (retry {retry_count}/{MAX_RETRIES})' if retry_count > 0 else ''}" ) + # Build extra_body for OpenRouter tracing and PostHog analytics + extra_body: dict[str, Any] = { + "posthogProperties": { + "environment": settings.config.app_env.value, + }, + } + if session.user_id: + extra_body["user"] = session.user_id[:128] # OpenRouter limit + extra_body["posthogDistinctId"] = session.user_id + if session.session_id: + extra_body["session_id"] = session.session_id[ + :128 + ] # OpenRouter limit + # Create the stream with proper types stream = await client.chat.completions.create( model=model, @@ -706,6 +752,7 @@ async def _stream_chat_chunks( tool_choice="auto", stream=True, stream_options={"include_usage": True}, + extra_body=extra_body, ) # Variables to accumulate tool calls diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py b/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py index 82ce5cfd6f..284273f3b9 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py @@ -1,8 +1,10 @@ +import logging from typing import TYPE_CHECKING, Any from openai.types.chat import ChatCompletionToolParam from backend.api.features.chat.model import ChatSession +from backend.api.features.chat.tracking import track_tool_called from .add_understanding import AddUnderstandingTool from .agent_output import AgentOutputTool @@ -20,6 +22,8 @@ from .search_docs import SearchDocsTool if TYPE_CHECKING: from backend.api.features.chat.response_model import StreamToolOutputAvailable +logger = logging.getLogger(__name__) + # Single source of truth for all tools TOOL_REGISTRY: dict[str, BaseTool] = { "add_understanding": AddUnderstandingTool(), @@ -56,4 +60,17 @@ async def execute_tool( tool = TOOL_REGISTRY.get(tool_name) if not tool: raise ValueError(f"Tool {tool_name} not found") + + # Track tool call in PostHog + logger.info( + f"Tracking tool call: tool={tool_name}, user={user_id}, " + f"session={session.session_id}, call_id={tool_call_id}" + ) + track_tool_called( + user_id=user_id, + session_id=session.session_id, + tool_name=tool_name, + tool_call_id=tool_call_id, + ) + return await tool.execute(user_id, session, tool_call_id, **parameters) diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/run_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/run_agent.py index b212c11e8a..88d432a797 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/run_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/run_agent.py @@ -8,6 +8,10 @@ from pydantic import BaseModel, Field, field_validator from backend.api.features.chat.config import ChatConfig from backend.api.features.chat.model import ChatSession +from backend.api.features.chat.tracking import ( + track_agent_run_success, + track_agent_scheduled, +) from backend.api.features.library import db as library_db from backend.data.graph import GraphModel from backend.data.model import CredentialsMetaInput @@ -453,6 +457,16 @@ class RunAgentTool(BaseTool): session.successful_agent_runs.get(library_agent.graph_id, 0) + 1 ) + # Track in PostHog + track_agent_run_success( + user_id=user_id, + session_id=session_id, + graph_id=library_agent.graph_id, + graph_name=library_agent.name, + execution_id=execution.id, + library_agent_id=library_agent.id, + ) + library_agent_link = f"/library/agents/{library_agent.id}" return ExecutionStartedResponse( message=( @@ -534,6 +548,18 @@ class RunAgentTool(BaseTool): session.successful_agent_schedules.get(library_agent.graph_id, 0) + 1 ) + # Track in PostHog + track_agent_scheduled( + user_id=user_id, + session_id=session_id, + graph_id=library_agent.graph_id, + graph_name=library_agent.name, + schedule_id=result.id, + schedule_name=schedule_name, + cron=cron, + library_agent_id=library_agent.id, + ) + library_agent_link = f"/library/agents/{library_agent.id}" return ExecutionStartedResponse( message=( diff --git a/autogpt_platform/backend/backend/api/features/chat/tracking.py b/autogpt_platform/backend/backend/api/features/chat/tracking.py new file mode 100644 index 0000000000..b2c0fd032f --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/chat/tracking.py @@ -0,0 +1,250 @@ +"""PostHog analytics tracking for the chat system.""" + +import atexit +import logging +from typing import Any + +from posthog import Posthog + +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) +settings = Settings() + +# PostHog client instance (lazily initialized) +_posthog_client: Posthog | None = None + + +def _shutdown_posthog() -> None: + """Flush and shutdown PostHog client on process exit.""" + if _posthog_client is not None: + _posthog_client.flush() + _posthog_client.shutdown() + + +atexit.register(_shutdown_posthog) + + +def _get_posthog_client() -> Posthog | None: + """Get or create the PostHog client instance.""" + global _posthog_client + if _posthog_client is not None: + return _posthog_client + + if not settings.secrets.posthog_api_key: + logger.debug("PostHog API key not configured, analytics disabled") + return None + + _posthog_client = Posthog( + settings.secrets.posthog_api_key, + host=settings.secrets.posthog_host, + ) + logger.info( + f"PostHog client initialized with host: {settings.secrets.posthog_host}" + ) + return _posthog_client + + +def _get_base_properties() -> dict[str, Any]: + """Get base properties included in all events.""" + return { + "environment": settings.config.app_env.value, + "source": "chat_copilot", + } + + +def track_user_message( + user_id: str | None, + session_id: str, + message_length: int, +) -> None: + """Track when a user sends a message in chat. + + Args: + user_id: The user's ID (or None for anonymous) + session_id: The chat session ID + message_length: Length of the user's message + """ + client = _get_posthog_client() + if not client: + return + + try: + properties = { + **_get_base_properties(), + "session_id": session_id, + "message_length": message_length, + } + client.capture( + distinct_id=user_id or f"anonymous_{session_id}", + event="copilot_message_sent", + properties=properties, + ) + except Exception as e: + logger.warning(f"Failed to track user message: {e}") + + +def track_tool_called( + user_id: str | None, + session_id: str, + tool_name: str, + tool_call_id: str, +) -> None: + """Track when a tool is called in chat. + + Args: + user_id: The user's ID (or None for anonymous) + session_id: The chat session ID + tool_name: Name of the tool being called + tool_call_id: Unique ID of the tool call + """ + client = _get_posthog_client() + if not client: + logger.info("PostHog client not available for tool tracking") + return + + try: + properties = { + **_get_base_properties(), + "session_id": session_id, + "tool_name": tool_name, + "tool_call_id": tool_call_id, + } + distinct_id = user_id or f"anonymous_{session_id}" + logger.info( + f"Sending copilot_tool_called event to PostHog: distinct_id={distinct_id}, " + f"tool_name={tool_name}" + ) + client.capture( + distinct_id=distinct_id, + event="copilot_tool_called", + properties=properties, + ) + except Exception as e: + logger.warning(f"Failed to track tool call: {e}") + + +def track_agent_run_success( + user_id: str, + session_id: str, + graph_id: str, + graph_name: str, + execution_id: str, + library_agent_id: str, +) -> None: + """Track when an agent is successfully run. + + Args: + user_id: The user's ID + session_id: The chat session ID + graph_id: ID of the agent graph + graph_name: Name of the agent + execution_id: ID of the execution + library_agent_id: ID of the library agent + """ + client = _get_posthog_client() + if not client: + return + + try: + properties = { + **_get_base_properties(), + "session_id": session_id, + "graph_id": graph_id, + "graph_name": graph_name, + "execution_id": execution_id, + "library_agent_id": library_agent_id, + } + client.capture( + distinct_id=user_id, + event="copilot_agent_run_success", + properties=properties, + ) + except Exception as e: + logger.warning(f"Failed to track agent run: {e}") + + +def track_agent_scheduled( + user_id: str, + session_id: str, + graph_id: str, + graph_name: str, + schedule_id: str, + schedule_name: str, + cron: str, + library_agent_id: str, +) -> None: + """Track when an agent is successfully scheduled. + + Args: + user_id: The user's ID + session_id: The chat session ID + graph_id: ID of the agent graph + graph_name: Name of the agent + schedule_id: ID of the schedule + schedule_name: Name of the schedule + cron: Cron expression for the schedule + library_agent_id: ID of the library agent + """ + client = _get_posthog_client() + if not client: + return + + try: + properties = { + **_get_base_properties(), + "session_id": session_id, + "graph_id": graph_id, + "graph_name": graph_name, + "schedule_id": schedule_id, + "schedule_name": schedule_name, + "cron": cron, + "library_agent_id": library_agent_id, + } + client.capture( + distinct_id=user_id, + event="copilot_agent_scheduled", + properties=properties, + ) + except Exception as e: + logger.warning(f"Failed to track agent schedule: {e}") + + +def track_trigger_setup( + user_id: str, + session_id: str, + graph_id: str, + graph_name: str, + trigger_type: str, + library_agent_id: str, +) -> None: + """Track when a trigger is set up for an agent. + + Args: + user_id: The user's ID + session_id: The chat session ID + graph_id: ID of the agent graph + graph_name: Name of the agent + trigger_type: Type of trigger (e.g., 'webhook') + library_agent_id: ID of the library agent + """ + client = _get_posthog_client() + if not client: + return + + try: + properties = { + **_get_base_properties(), + "session_id": session_id, + "graph_id": graph_id, + "graph_name": graph_name, + "trigger_type": trigger_type, + "library_agent_id": library_agent_id, + } + client.capture( + distinct_id=user_id, + event="copilot_trigger_setup", + properties=properties, + ) + except Exception as e: + logger.warning(f"Failed to track trigger setup: {e}") diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 2972fc07c2..8d34292803 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -679,6 +679,12 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): default="https://cloud.langfuse.com", description="Langfuse host URL" ) + # PostHog analytics + posthog_api_key: str = Field(default="", description="PostHog API key") + posthog_host: str = Field( + default="https://eu.i.posthog.com", description="PostHog host URL" + ) + # Add more secret fields as needed model_config = SettingsConfigDict( env_file=".env", diff --git a/autogpt_platform/backend/poetry.lock b/autogpt_platform/backend/poetry.lock index 2aa55ce5b6..91ac358ade 100644 --- a/autogpt_platform/backend/poetry.lock +++ b/autogpt_platform/backend/poetry.lock @@ -4204,14 +4204,14 @@ strenum = {version = ">=0.4.9,<0.5.0", markers = "python_version < \"3.11\""} [[package]] name = "posthog" -version = "6.1.1" +version = "7.6.0" description = "Integrate PostHog into any python application." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "posthog-6.1.1-py3-none-any.whl", hash = "sha256:329fd3d06b4d54cec925f47235bd8e327c91403c2f9ec38f1deb849535934dba"}, - {file = "posthog-6.1.1.tar.gz", hash = "sha256:b453f54c4a2589da859fd575dd3bf86fcb40580727ec399535f268b1b9f318b8"}, + {file = "posthog-7.6.0-py3-none-any.whl", hash = "sha256:c4dd78cf77c4fecceb965f86066e5ac37886ef867d68ffe75a1db5d681d7d9ad"}, + {file = "posthog-7.6.0.tar.gz", hash = "sha256:941dfd278ee427c9b14640f09b35b5bb52a71bdf028d7dbb7307e1838fd3002e"}, ] [package.dependencies] @@ -4225,7 +4225,7 @@ typing-extensions = ">=4.2.0" [package.extras] dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"] langchain = ["langchain (>=0.2.0)"] -test = ["anthropic", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=0.3.15)", "langchain-community (>=0.3.25)", "langchain-core (>=0.3.65)", "langchain-openai (>=0.3.22)", "langgraph (>=0.4.8)", "mock (>=2.0.0)", "openai", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"] +test = ["anthropic (>=0.72)", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=1.0)", "langchain-community (>=0.4)", "langchain-core (>=1.0)", "langchain-openai (>=1.0)", "langgraph (>=1.0)", "mock (>=2.0.0)", "openai (>=2.0)", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"] [[package]] name = "postmarker" @@ -7512,4 +7512,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.14" -content-hash = "18b92e09596298c82432e4d0a85cb6d80a40b4229bee0a0c15f0529fd6cb21a4" +content-hash = "ee5742dc1a9df50dfc06d4b26a1682cbb2b25cab6b79ce5625ec272f93e4f4bf" diff --git a/autogpt_platform/backend/pyproject.toml b/autogpt_platform/backend/pyproject.toml index 1f489d2bc4..fe263e47c0 100644 --- a/autogpt_platform/backend/pyproject.toml +++ b/autogpt_platform/backend/pyproject.toml @@ -85,6 +85,7 @@ exa-py = "^1.14.20" croniter = "^6.0.0" stagehand = "^0.5.1" gravitas-md2gdocs = "^0.1.0" +posthog = "^7.6.0" [tool.poetry.group.dev.dependencies] aiohappyeyeballs = "^2.6.1" diff --git a/autogpt_platform/frontend/.env.default b/autogpt_platform/frontend/.env.default index 197a37e8bb..af250fb8bf 100644 --- a/autogpt_platform/frontend/.env.default +++ b/autogpt_platform/frontend/.env.default @@ -30,3 +30,7 @@ NEXT_PUBLIC_TURNSTILE=disabled # PR previews NEXT_PUBLIC_PREVIEW_STEALING_DEV= + +# PostHog Analytics +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index bc1e2d7443..f22a182d20 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -34,6 +34,7 @@ "@hookform/resolvers": "5.2.2", "@next/third-parties": "15.4.6", "@phosphor-icons/react": "2.1.10", + "@posthog/react": "1.7.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-avatar": "1.1.10", @@ -91,6 +92,7 @@ "next-themes": "0.4.6", "nuqs": "2.7.2", "party-js": "2.2.0", + "posthog-js": "1.334.1", "react": "18.3.1", "react-currency-input-field": "4.0.3", "react-day-picker": "9.11.1", @@ -120,7 +122,6 @@ }, "devDependencies": { "@chromatic-com/storybook": "4.1.2", - "happy-dom": "20.3.4", "@opentelemetry/instrumentation": "0.209.0", "@playwright/test": "1.56.1", "@storybook/addon-a11y": "9.1.5", @@ -148,6 +149,7 @@ "eslint": "8.57.1", "eslint-config-next": "15.5.7", "eslint-plugin-storybook": "9.1.5", + "happy-dom": "20.3.4", "import-in-the-middle": "2.0.2", "msw": "2.11.6", "msw-storybook-addon": "2.0.6", diff --git a/autogpt_platform/frontend/pnpm-lock.yaml b/autogpt_platform/frontend/pnpm-lock.yaml index 8e83289f03..db891ccf3f 100644 --- a/autogpt_platform/frontend/pnpm-lock.yaml +++ b/autogpt_platform/frontend/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@phosphor-icons/react': specifier: 2.1.10 version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@posthog/react': + specifier: 1.7.0 + version: 1.7.0(@types/react@18.3.17)(posthog-js@1.334.1)(react@18.3.1) '@radix-ui/react-accordion': specifier: 1.2.12 version: 1.2.12(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -194,6 +197,9 @@ importers: party-js: specifier: 2.2.0 version: 2.2.0 + posthog-js: + specifier: 1.334.1 + version: 1.334.1 react: specifier: 18.3.1 version: 18.3.1 @@ -1794,6 +1800,10 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.209.0': resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==} engines: {node: '>=8.0.0'} @@ -1814,6 +1824,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-logs-otlp-http@0.208.0': + resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-amqplib@0.55.0': resolution: {integrity: sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1952,6 +1968,18 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.208.0': + resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.208.0': + resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/redis-common@0.38.2': resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1962,6 +1990,18 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-logs@0.208.0': + resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.2.0': resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -2050,11 +2090,57 @@ packages: webpack-plugin-serve: optional: true + '@posthog/core@1.13.0': + resolution: {integrity: sha512-knjncrk7qRmssFRbGzBl1Tunt21GRpe0Wv+uVelyL0Rh7PdQUsgguulzXFTps8hA6wPwTU4kq85qnbAJ3eH6Wg==} + + '@posthog/react@1.7.0': + resolution: {integrity: sha512-pM7GL7z/rKjiIwosbRiQA3buhLI6vUo+wg+T/ZrVZC7O5bVU07TfgNZTcuOj8E9dx7vDbfNrc1kjDN7PKMM8ug==} + peerDependencies: + '@types/react': '>=16.8.0' + posthog-js: '>=1.257.2' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@posthog/types@1.334.1': + resolution: {integrity: sha512-ypFnwTO7qbV7icylLbujbamPdQXbJq0a61GUUBnJAeTbBw/qYPIss5IRYICcbCj0uunQrwD7/CGxVb5TOYKWgA==} + '@prisma/instrumentation@6.19.0': resolution: {integrity: sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg==} peerDependencies: '@opentelemetry/api': ^1.8 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3401,6 +3487,9 @@ packages: '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -4278,6 +4367,9 @@ packages: core-js-pure@3.47.0: resolution: {integrity: sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==} + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -4569,6 +4661,9 @@ packages: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -4939,6 +5034,9 @@ packages: picomatch: optional: true + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -5745,6 +5843,9 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -6534,6 +6635,12 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + posthog-js@1.334.1: + resolution: {integrity: sha512-5cDzLICr2afnwX/cR9fwoLC0vN0Nb5gP5HiCigzHkgHdO+E3WsYefla3EFMQz7U4r01CBPZ+nZ9/srkzeACxtQ==} + + preact@10.28.2: + resolution: {integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6622,6 +6729,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -6643,6 +6754,9 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + querystring-es3@0.2.1: resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} engines: {node: '>=0.4.x'} @@ -7821,6 +7935,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-vitals@5.1.0: + resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -9420,6 +9537,10 @@ snapshots: '@open-draft/until@2.1.0': {} + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs@0.209.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -9435,6 +9556,15 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib@0.55.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -9629,6 +9759,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 + '@opentelemetry/redis-common@0.38.2': {} '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0)': @@ -9637,6 +9784,19 @@ snapshots: '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -9801,6 +9961,19 @@ snapshots: type-fest: 4.41.0 webpack-hot-middleware: 2.26.1 + '@posthog/core@1.13.0': + dependencies: + cross-spawn: 7.0.6 + + '@posthog/react@1.7.0(@types/react@18.3.17)(posthog-js@1.334.1)(react@18.3.1)': + dependencies: + posthog-js: 1.334.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.17 + + '@posthog/types@1.334.1': {} + '@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -9808,6 +9981,29 @@ snapshots: transitivePeerDependencies: - supports-color + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -11426,6 +11622,9 @@ snapshots: dependencies: '@types/node': 24.10.0 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -12327,6 +12526,8 @@ snapshots: core-js-pure@3.47.0: {} + core-js@3.48.0: {} + core-util-is@1.0.3: {} cosmiconfig@7.1.0: @@ -12636,6 +12837,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -13205,6 +13410,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.4.8: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -14092,6 +14299,8 @@ snapshots: loglevel@1.9.2: {} + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -15154,6 +15363,24 @@ snapshots: dependencies: xtend: 4.0.2 + posthog-js@1.334.1: + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@posthog/core': 1.13.0 + '@posthog/types': 1.334.1 + core-js: 3.48.0 + dompurify: 3.3.1 + fflate: 0.4.8 + preact: 10.28.2 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.1.0 + + preact@10.28.2: {} + prelude-ls@1.2.1: {} prettier-plugin-tailwindcss@0.7.1(prettier@3.6.2): @@ -15187,6 +15414,21 @@ snapshots: property-information@7.1.0: {} + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 24.10.0 + long: 5.3.2 + proxy-from-env@1.1.0: {} public-encrypt@4.0.3: @@ -15208,6 +15450,8 @@ snapshots: dependencies: side-channel: 1.1.0 + query-selector-shadow-dom@1.0.1: {} + querystring-es3@0.2.1: {} queue-microtask@1.2.3: {} @@ -16619,6 +16863,8 @@ snapshots: web-namespaces@2.0.1: {} + web-vitals@5.1.0: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: diff --git a/autogpt_platform/frontend/src/app/providers.tsx b/autogpt_platform/frontend/src/app/providers.tsx index 8ea199abc8..267814e7c2 100644 --- a/autogpt_platform/frontend/src/app/providers.tsx +++ b/autogpt_platform/frontend/src/app/providers.tsx @@ -6,28 +6,40 @@ import { BackendAPIProvider } from "@/lib/autogpt-server-api/context"; import { getQueryClient } from "@/lib/react-query/queryClient"; import CredentialsProvider from "@/providers/agent-credentials/credentials-provider"; import OnboardingProvider from "@/providers/onboarding/onboarding-provider"; +import { + PostHogPageViewTracker, + PostHogProvider, + PostHogUserTracker, +} from "@/providers/posthog/posthog-provider"; import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider"; import { QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider, ThemeProviderProps } from "next-themes"; import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { Suspense } from "react"; export function Providers({ children, ...props }: ThemeProviderProps) { const queryClient = getQueryClient(); return ( - - - - - - - {children} - - - - - + + + + + + + + + + + + {children} + + + + + + ); diff --git a/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx b/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx new file mode 100644 index 0000000000..58ee2394ef --- /dev/null +++ b/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; +import { PostHogProvider as PHProvider } from "@posthog/react"; +import { usePathname, useSearchParams } from "next/navigation"; +import posthog from "posthog-js"; +import { ReactNode, useEffect, useRef } from "react"; + +export function PostHogProvider({ children }: { children: ReactNode }) { + useEffect(() => { + if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + defaults: "2025-11-30", + capture_pageview: false, + capture_pageleave: true, + autocapture: true, + }); + } + }, []); + + return {children}; +} + +export function PostHogUserTracker() { + const { user, isUserLoading } = useSupabase(); + const previousUserIdRef = useRef(null); + + useEffect(() => { + if (isUserLoading) return; + + if (user) { + if (previousUserIdRef.current !== user.id) { + posthog.identify(user.id, { + email: user.email, + ...(user.user_metadata?.name && { name: user.user_metadata.name }), + }); + previousUserIdRef.current = user.id; + } + } else if (previousUserIdRef.current !== null) { + posthog.reset(); + previousUserIdRef.current = null; + } + }, [user, isUserLoading]); + + return null; +} + +export function PostHogPageViewTracker() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + if (pathname) { + let url = window.origin + pathname; + if (searchParams && searchParams.toString()) { + url = url + `?${searchParams.toString()}`; + } + posthog.capture("$pageview", { $current_url: url }); + } + }, [pathname, searchParams]); + + return null; +} From f0c25036082a5e53650ae7230bcfcf9309bbb5d5 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:03:22 +0530 Subject: [PATCH 09/36] feat(frontend): support multiple node execution results and accumulated data display (#11834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changes šŸ—ļø - Refactored node execution results storage to maintain a history of executions instead of just the latest result - Added support for viewing accumulated output data across multiple executions - Implemented a cleaner UI for viewing historical execution results with proper grouping - Added functionality to clear execution results when starting a new run - Created helper functions to normalize and process execution data consistently - Updated the NodeDataViewer component to display both latest and historical execution data - Added ability to view input data alongside output data in the execution history ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Create and run a flow with multiple blocks that produce output - [x] Verify that execution results are properly accumulated and displayed - [x] Run the same flow multiple times and confirm historical data is preserved - [x] Test the "View more data" functionality to ensure it displays all execution history - [x] Verify that execution results are properly cleared when starting a new run --- .../components/AgentOutputs/AgentOutputs.tsx | 8 +- .../RunInputDialog/useRunInputDialog.ts | 3 + .../nodes/CustomNode/CustomNode.tsx | 8 +- .../components/NodeOutput/NodeOutput.tsx | 136 +++++------ .../NodeDataViewer/NodeDataViewer.tsx | 199 +++++++++++++--- .../NodeDataViewer/useNodeDataViewer.ts | 160 +++++++------ .../NodeOutput/components/ViewMoreData.tsx | 189 +++++++++------ .../components/NodeOutput/helpers.ts | 83 +++++++ .../components/NodeOutput/useNodeOutput.tsx | 22 +- .../SubAgentUpdate/useSubAgentUpdateState.ts | 6 +- .../FlowEditor/nodes/CustomNode/helpers.ts | 2 +- .../app/(platform)/build/stores/helpers.ts | 16 ++ .../app/(platform)/build/stores/nodeStore.ts | 217 +++++++++++++++--- .../src/app/(platform)/build/stores/types.ts | 14 ++ 14 files changed, 782 insertions(+), 281 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx index cfea5d9452..8ec1ba8be3 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx @@ -38,8 +38,12 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => { return outputNodes .map((node) => { - const executionResult = node.data.nodeExecutionResult; - const outputData = executionResult?.output_data?.output; + const executionResults = node.data.nodeExecutionResults || []; + const latestResult = + executionResults.length > 0 + ? executionResults[executionResults.length - 1] + : undefined; + const outputData = latestResult?.output_data?.output; const renderer = globalRegistry.getRenderer(outputData); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts index 0eba6e8188..629d4662a9 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts @@ -153,6 +153,9 @@ export const useRunInputDialog = ({ Object.entries(credentialValues).filter(([_, cred]) => cred && cred.id), ); + useNodeStore.getState().clearAllNodeExecutionResults(); + useNodeStore.getState().cleanNodesStatuses(); + await executeGraph({ graphId: flowID ?? "", graphVersion: flowVersion || null, diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx index 6306582c3b..d4aa26480d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx @@ -34,7 +34,7 @@ export type CustomNodeData = { uiType: BlockUIType; block_id: string; status?: AgentExecutionStatus; - nodeExecutionResult?: NodeExecutionResult; + nodeExecutionResults?: NodeExecutionResult[]; staticOutput?: boolean; // TODO : We need better type safety for the following backend fields. costs: BlockCost[]; @@ -75,7 +75,11 @@ export const CustomNode: React.FC> = React.memo( (value) => value !== null && value !== undefined && value !== "", ); - const outputData = data.nodeExecutionResult?.output_data; + const latestResult = + data.nodeExecutionResults && data.nodeExecutionResults.length > 0 + ? data.nodeExecutionResults[data.nodeExecutionResults.length - 1] + : undefined; + const outputData = latestResult?.output_data; const hasOutputError = typeof outputData === "object" && outputData !== null && diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx index 17134ae299..c5df24e0e6 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx @@ -14,10 +14,15 @@ import { useNodeOutput } from "./useNodeOutput"; import { ViewMoreData } from "./components/ViewMoreData"; export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => { - const { outputData, copiedKey, handleCopy, executionResultId, inputData } = - useNodeOutput(nodeId); + const { + latestOutputData, + copiedKey, + handleCopy, + executionResultId, + latestInputData, + } = useNodeOutput(nodeId); - if (Object.keys(outputData).length === 0) { + if (Object.keys(latestOutputData).length === 0) { return null; } @@ -41,18 +46,19 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
Input - +
- {Object.entries(outputData) + {Object.entries(latestOutputData) .slice(0, 2) - .map(([key, value]) => ( -
-
- - Pin: - - - {beautifyString(key)} - -
-
- - Data: - -
- {value.map((item, index) => ( -
- -
- ))} + .map(([key, value]) => { + return ( +
+
+ + Pin: + + + {beautifyString(key)} + +
+
+ + Data: + +
+ {value.map((item, index) => ( +
+ +
+ ))} -
- - +
+ + +
-
- ))} + ); + })}
- - {Object.keys(outputData).length > 2 && ( - - )} + diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx index 0858db8f0e..680b6bc44a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx @@ -19,22 +19,51 @@ import { CopyIcon, DownloadIcon, } from "@phosphor-icons/react"; -import { FC } from "react"; +import React, { FC } from "react"; import { useNodeDataViewer } from "./useNodeDataViewer"; +import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; +import { useShallow } from "zustand/react/shallow"; +import { NodeDataType } from "../../helpers"; -interface NodeDataViewerProps { - data: any; +export interface NodeDataViewerProps { + data?: any; pinName: string; + nodeId?: string; execId?: string; isViewMoreData?: boolean; + dataType?: NodeDataType; } export const NodeDataViewer: FC = ({ data, pinName, + nodeId, execId = "N/A", isViewMoreData = false, + dataType = "output", }) => { + const executionResults = useNodeStore( + useShallow((state) => + nodeId ? state.getNodeExecutionResults(nodeId) : [], + ), + ); + const latestInputData = useNodeStore( + useShallow((state) => + nodeId ? state.getLatestNodeInputData(nodeId) : undefined, + ), + ); + const accumulatedOutputData = useNodeStore( + useShallow((state) => + nodeId ? state.getAccumulatedNodeOutputData(nodeId) : {}, + ), + ); + + const resolvedData = + data ?? + (dataType === "input" + ? (latestInputData ?? {}) + : (accumulatedOutputData[pinName] ?? [])); + const { outputItems, copyExecutionId, @@ -42,7 +71,20 @@ export const NodeDataViewer: FC = ({ handleDownloadItem, dataArray, copiedIndex, - } = useNodeDataViewer(data, pinName, execId); + groupedExecutions, + totalGroupedItems, + handleCopyGroupedItem, + handleDownloadGroupedItem, + copiedKey, + } = useNodeDataViewer( + resolvedData, + pinName, + execId, + executionResults, + dataType, + ); + + const shouldGroupExecutions = groupedExecutions.length > 0; return ( @@ -68,44 +110,141 @@ export const NodeDataViewer: FC = ({
- Full Output Preview + Full {dataType === "input" ? "Input" : "Output"} Preview
- {dataArray.length} item{dataArray.length !== 1 ? "s" : ""} total + {shouldGroupExecutions ? totalGroupedItems : dataArray.length}{" "} + item + {shouldGroupExecutions + ? totalGroupedItems !== 1 + ? "s" + : "" + : dataArray.length !== 1 + ? "s" + : ""}{" "} + total
-
- - Execution ID: - - - {execId} - - -
-
- Pin:{" "} - {beautifyString(pinName)} -
+ {shouldGroupExecutions ? ( +
+ Pin:{" "} + {beautifyString(pinName)} +
+ ) : ( + <> +
+ + Execution ID: + + + {execId} + + +
+
+ Pin:{" "} + + {beautifyString(pinName)} + +
+ + )}
- {dataArray.length > 0 ? ( + {shouldGroupExecutions ? ( +
+ {groupedExecutions.map((execution) => ( +
+
+ + Execution ID: + + + {execution.execId} + +
+
+ {execution.outputItems.length > 0 ? ( + execution.outputItems.map((item, index) => ( +
+
+ +
+ +
+ + +
+
+ )) + ) : ( +
+ No data available +
+ )} +
+
+ ))} +
+ ) : dataArray.length > 0 ? (
{outputItems.map((item, index) => (
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts index d3c555970c..818d1266c1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts @@ -1,82 +1,70 @@ -import type { OutputMetadata } from "@/components/contextual/OutputRenderers"; -import { globalRegistry } from "@/components/contextual/OutputRenderers"; import { downloadOutputs } from "@/components/contextual/OutputRenderers/utils/download"; import { useToast } from "@/components/molecules/Toast/use-toast"; import { beautifyString } from "@/lib/utils"; -import React, { useMemo, useState } from "react"; +import { useState } from "react"; +import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult"; +import { + NodeDataType, + createOutputItems, + getExecutionData, + normalizeToArray, + type OutputItem, +} from "../../helpers"; + +export type GroupedExecution = { + execId: string; + outputItems: Array; +}; export const useNodeDataViewer = ( data: any, pinName: string, execId: string, + executionResults?: NodeExecutionResult[], + dataType?: NodeDataType, ) => { const { toast } = useToast(); const [copiedIndex, setCopiedIndex] = useState(null); + const [copiedKey, setCopiedKey] = useState(null); - // Normalize data to array format - const dataArray = useMemo(() => { - return Array.isArray(data) ? data : [data]; - }, [data]); + const dataArray = Array.isArray(data) ? data : [data]; - // Prepare items for the enhanced renderer system - const outputItems = useMemo(() => { - if (!dataArray) return []; - - const items: Array<{ - key: string; - label: string; - value: unknown; - metadata?: OutputMetadata; - renderer: any; - }> = []; - - dataArray.forEach((value, index) => { - const metadata: OutputMetadata = {}; - - // Extract metadata from the value if it's an object - if ( - typeof value === "object" && - value !== null && - !React.isValidElement(value) - ) { - const objValue = value as any; - if (objValue.type) metadata.type = objValue.type; - if (objValue.mimeType) metadata.mimeType = objValue.mimeType; - if (objValue.filename) metadata.filename = objValue.filename; - if (objValue.language) metadata.language = objValue.language; - } - - const renderer = globalRegistry.getRenderer(value, metadata); - if (renderer) { - items.push({ - key: `item-${index}`, + const outputItems = + !dataArray || dataArray.length === 0 + ? [] + : createOutputItems(dataArray).map((item, index) => ({ + ...item, label: index === 0 ? beautifyString(pinName) : "", - value, - metadata, - renderer, - }); - } else { - // Fallback to text renderer - const textRenderer = globalRegistry - .getAllRenderers() - .find((r) => r.name === "TextRenderer"); - if (textRenderer) { - items.push({ - key: `item-${index}`, - label: index === 0 ? beautifyString(pinName) : "", - value: - typeof value === "string" - ? value - : JSON.stringify(value, null, 2), - metadata, - renderer: textRenderer, - }); - } - } - }); + })); - return items; - }, [dataArray, pinName]); + const groupedExecutions = + !executionResults || executionResults.length === 0 + ? [] + : [...executionResults].reverse().map((result) => { + const rawData = getExecutionData( + result, + dataType || "output", + pinName, + ); + let dataArray: unknown[]; + if (dataType === "input") { + dataArray = + rawData !== undefined && rawData !== null ? [rawData] : []; + } else { + dataArray = normalizeToArray(rawData); + } + + const outputItems = createOutputItems(dataArray); + return { + execId: result.node_exec_id, + outputItems, + }; + }); + + const totalGroupedItems = groupedExecutions.reduce( + (total, execution) => total + execution.outputItems.length, + 0, + ); const copyExecutionId = () => { navigator.clipboard.writeText(execId).then(() => { @@ -122,6 +110,45 @@ export const useNodeDataViewer = ( ]); }; + const handleCopyGroupedItem = async ( + execId: string, + index: number, + item: OutputItem, + ) => { + const copyContent = item.renderer.getCopyContent(item.value, item.metadata); + + if (!copyContent) { + return; + } + + try { + let text: string; + if (typeof copyContent.data === "string") { + text = copyContent.data; + } else if (copyContent.fallbackText) { + text = copyContent.fallbackText; + } else { + return; + } + + await navigator.clipboard.writeText(text); + setCopiedKey(`${execId}-${index}`); + setTimeout(() => setCopiedKey(null), 2000); + } catch (error) { + console.error("Failed to copy:", error); + } + }; + + const handleDownloadGroupedItem = (item: OutputItem) => { + downloadOutputs([ + { + value: item.value, + metadata: item.metadata, + renderer: item.renderer, + }, + ]); + }; + return { outputItems, dataArray, @@ -129,5 +156,10 @@ export const useNodeDataViewer = ( handleCopyItem, handleDownloadItem, copiedIndex, + groupedExecutions, + totalGroupedItems, + handleCopyGroupedItem, + handleDownloadGroupedItem, + copiedKey, }; }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx index 7bf026fe43..74d0da06c2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx @@ -8,16 +8,28 @@ import { useState } from "react"; import { NodeDataViewer } from "./NodeDataViewer/NodeDataViewer"; import { useToast } from "@/components/molecules/Toast/use-toast"; import { CheckIcon, CopyIcon } from "@phosphor-icons/react"; +import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; +import { useShallow } from "zustand/react/shallow"; +import { + NodeDataType, + getExecutionEntries, + normalizeToArray, +} from "../helpers"; export const ViewMoreData = ({ - outputData, - execId, + nodeId, + dataType = "output", }: { - outputData: Record>; - execId?: string; + nodeId: string; + dataType?: NodeDataType; }) => { const [copiedKey, setCopiedKey] = useState(null); const { toast } = useToast(); + const executionResults = useNodeStore( + useShallow((state) => state.getNodeExecutionResults(nodeId)), + ); + + const reversedExecutionResults = [...executionResults].reverse(); const handleCopy = (key: string, value: any) => { const textToCopy = @@ -29,8 +41,8 @@ export const ViewMoreData = ({ setTimeout(() => setCopiedKey(null), 2000); }; - const copyExecutionId = () => { - navigator.clipboard.writeText(execId || "N/A").then(() => { + const copyExecutionId = (executionId: string) => { + navigator.clipboard.writeText(executionId || "N/A").then(() => { toast({ title: "Execution ID copied to clipboard!", duration: 2000, @@ -42,7 +54,7 @@ export const ViewMoreData = ({ -
-
- {Object.entries(outputData).map(([key, value]) => ( -
+ {reversedExecutionResults.map((result) => ( +
+ + Execution ID: + - Pin: - - - {beautifyString(key)} + {result.node_exec_id} +
-
- - Data: - -
- {value.map((item, index) => ( -
- -
- ))} -
- - -
-
+
+ {getExecutionEntries(result, dataType).map( + ([key, value]) => { + const normalizedValue = normalizeToArray(value); + return ( +
+
+ + Pin: + + + {beautifyString(key)} + +
+
+ + Data: + +
+ {normalizedValue.map((item, index) => ( +
+ +
+ ))} + +
+ + +
+
+
+
+ ); + }, + )}
))} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts new file mode 100644 index 0000000000..c75cd83cac --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts @@ -0,0 +1,83 @@ +import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult"; +import type { OutputMetadata } from "@/components/contextual/OutputRenderers"; +import { globalRegistry } from "@/components/contextual/OutputRenderers"; +import React from "react"; + +export type NodeDataType = "input" | "output"; + +export type OutputItem = { + key: string; + value: unknown; + metadata?: OutputMetadata; + renderer: any; +}; + +export const normalizeToArray = (value: unknown) => { + if (value === undefined) return []; + return Array.isArray(value) ? value : [value]; +}; + +export const getExecutionData = ( + result: NodeExecutionResult, + dataType: NodeDataType, + pinName: string, +) => { + if (dataType === "input") { + return result.input_data; + } + + return result.output_data?.[pinName]; +}; + +export const createOutputItems = (dataArray: unknown[]): Array => { + const items: Array = []; + + dataArray.forEach((value, index) => { + const metadata: OutputMetadata = {}; + + if ( + typeof value === "object" && + value !== null && + !React.isValidElement(value) + ) { + const objValue = value as any; + if (objValue.type) metadata.type = objValue.type; + if (objValue.mimeType) metadata.mimeType = objValue.mimeType; + if (objValue.filename) metadata.filename = objValue.filename; + if (objValue.language) metadata.language = objValue.language; + } + + const renderer = globalRegistry.getRenderer(value, metadata); + if (renderer) { + items.push({ + key: `item-${index}`, + value, + metadata, + renderer, + }); + } else { + const textRenderer = globalRegistry + .getAllRenderers() + .find((r) => r.name === "TextRenderer"); + if (textRenderer) { + items.push({ + key: `item-${index}`, + value: + typeof value === "string" ? value : JSON.stringify(value, null, 2), + metadata, + renderer: textRenderer, + }); + } + } + }); + + return items; +}; + +export const getExecutionEntries = ( + result: NodeExecutionResult, + dataType: NodeDataType, +) => { + const data = dataType === "input" ? result.input_data : result.output_data; + return Object.entries(data || {}); +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx index cfc599c6e4..8ebf1dfaf3 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx @@ -7,15 +7,18 @@ export const useNodeOutput = (nodeId: string) => { const [copiedKey, setCopiedKey] = useState(null); const { toast } = useToast(); - const nodeExecutionResult = useNodeStore( - useShallow((state) => state.getNodeExecutionResult(nodeId)), + const latestResult = useNodeStore( + useShallow((state) => state.getLatestNodeExecutionResult(nodeId)), ); - const inputData = nodeExecutionResult?.input_data; + const latestInputData = useNodeStore( + useShallow((state) => state.getLatestNodeInputData(nodeId)), + ); + + const latestOutputData: Record> = useNodeStore( + useShallow((state) => state.getLatestNodeOutputData(nodeId) || {}), + ); - const outputData: Record> = { - ...nodeExecutionResult?.output_data, - }; const handleCopy = async (key: string, value: any) => { try { const text = JSON.stringify(value, null, 2); @@ -35,11 +38,12 @@ export const useNodeOutput = (nodeId: string) => { }); } }; + return { - outputData, - inputData, + latestOutputData, + latestInputData, copiedKey, handleCopy, - executionResultId: nodeExecutionResult?.node_exec_id, + executionResultId: latestResult?.node_exec_id, }; }; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts index d4ba538172..143cd58509 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts @@ -1,10 +1,7 @@ import { useState, useCallback, useEffect } from "react"; import { useShallow } from "zustand/react/shallow"; import { useGraphStore } from "@/app/(platform)/build/stores/graphStore"; -import { - useNodeStore, - NodeResolutionData, -} from "@/app/(platform)/build/stores/nodeStore"; +import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore"; import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore"; import { useSubAgentUpdate, @@ -13,6 +10,7 @@ import { } from "@/app/(platform)/build/hooks/useSubAgentUpdate"; import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api"; import { CustomNodeData } from "../../CustomNode"; +import { NodeResolutionData } from "@/app/(platform)/build/stores/types"; // Stable empty set to avoid creating new references in selectors const EMPTY_SET: Set = new Set(); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts index 54ddf2a61d..50326a03e6 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts @@ -1,5 +1,5 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; -import { NodeResolutionData } from "@/app/(platform)/build/stores/nodeStore"; +import { NodeResolutionData } from "@/app/(platform)/build/stores/types"; import { RJSFSchema } from "@rjsf/utils"; export const nodeStyleBasedOnStatus: Record = { diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts new file mode 100644 index 0000000000..bcdfd4c313 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts @@ -0,0 +1,16 @@ +export const accumulateExecutionData = ( + accumulated: Record, + data: Record | undefined, +) => { + if (!data) return { ...accumulated }; + const next = { ...accumulated }; + Object.entries(data).forEach(([key, values]) => { + const nextValues = Array.isArray(values) ? values : [values]; + if (next[key]) { + next[key] = [...next[key], ...nextValues]; + } else { + next[key] = [...nextValues]; + } + }); + return next; +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts index 5502a8780d..f7a52636f3 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts @@ -10,6 +10,8 @@ import { import { Node } from "@/app/api/__generated__/models/node"; import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus"; import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult"; +import { NodeExecutionResultInputData } from "@/app/api/__generated__/models/nodeExecutionResultInputData"; +import { NodeExecutionResultOutputData } from "@/app/api/__generated__/models/nodeExecutionResultOutputData"; import { useHistoryStore } from "./historyStore"; import { useEdgeStore } from "./edgeStore"; import { BlockUIType } from "../components/types"; @@ -18,31 +20,10 @@ import { ensurePathExists, parseHandleIdToPath, } from "@/components/renderers/InputRenderer/helpers"; -import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types"; +import { accumulateExecutionData } from "./helpers"; +import { NodeResolutionData } from "./types"; -// Resolution mode data stored per node -export type NodeResolutionData = { - incompatibilities: IncompatibilityInfo; - // The NEW schema from the update (what we're updating TO) - pendingUpdate: { - input_schema: Record; - output_schema: Record; - }; - // The OLD schema before the update (what we're updating FROM) - // Needed to merge and show removed inputs during resolution - currentSchema: { - input_schema: Record; - output_schema: Record; - }; - // The full updated hardcoded values to apply when resolution completes - pendingHardcodedValues: Record; -}; - -// Minimum movement (in pixels) required before logging position change to history -// Prevents spamming history with small movements when clicking on inputs inside blocks const MINIMUM_MOVE_BEFORE_LOG = 50; - -// Track initial positions when drag starts (outside store to avoid re-renders) const dragStartPositions: Record = {}; let dragStartState: { nodes: CustomNode[]; edges: CustomEdge[] } | null = null; @@ -52,6 +33,15 @@ type NodeStore = { nodeCounter: number; setNodeCounter: (nodeCounter: number) => void; nodeAdvancedStates: Record; + + latestNodeInputData: Record; + latestNodeOutputData: Record< + string, + NodeExecutionResultOutputData | undefined + >; + accumulatedNodeInputData: Record>; + accumulatedNodeOutputData: Record>; + setNodes: (nodes: CustomNode[]) => void; onNodesChange: (changes: NodeChange[]) => void; addNode: (node: CustomNode) => void; @@ -72,12 +62,26 @@ type NodeStore = { updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => void; getNodeStatus: (nodeId: string) => AgentExecutionStatus | undefined; + cleanNodesStatuses: () => void; updateNodeExecutionResult: ( nodeId: string, result: NodeExecutionResult, ) => void; - getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined; + getNodeExecutionResults: (nodeId: string) => NodeExecutionResult[]; + getLatestNodeInputData: ( + nodeId: string, + ) => NodeExecutionResultInputData | undefined; + getLatestNodeOutputData: ( + nodeId: string, + ) => NodeExecutionResultOutputData | undefined; + getAccumulatedNodeInputData: (nodeId: string) => Record; + getAccumulatedNodeOutputData: (nodeId: string) => Record; + getLatestNodeExecutionResult: ( + nodeId: string, + ) => NodeExecutionResult | undefined; + clearAllNodeExecutionResults: () => void; + getNodeBlockUIType: (nodeId: string) => BlockUIType; hasWebhookNodes: () => boolean; @@ -122,6 +126,10 @@ export const useNodeStore = create((set, get) => ({ nodeCounter: 0, setNodeCounter: (nodeCounter) => set({ nodeCounter }), nodeAdvancedStates: {}, + latestNodeInputData: {}, + latestNodeOutputData: {}, + accumulatedNodeInputData: {}, + accumulatedNodeOutputData: {}, incrementNodeCounter: () => set((state) => ({ nodeCounter: state.nodeCounter + 1, @@ -317,17 +325,162 @@ export const useNodeStore = create((set, get) => ({ return get().nodes.find((n) => n.id === nodeId)?.data?.status; }, - updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => { + cleanNodesStatuses: () => { set((state) => ({ - nodes: state.nodes.map((n) => - n.id === nodeId - ? { ...n, data: { ...n.data, nodeExecutionResult: result } } - : n, - ), + nodes: state.nodes.map((n) => ({ + ...n, + data: { ...n.data, status: undefined }, + })), })); }, - getNodeExecutionResult: (nodeId: string) => { - return get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResult; + + updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => { + set((state) => { + let latestNodeInputData = state.latestNodeInputData; + let latestNodeOutputData = state.latestNodeOutputData; + let accumulatedNodeInputData = state.accumulatedNodeInputData; + let accumulatedNodeOutputData = state.accumulatedNodeOutputData; + + const nodes = state.nodes.map((n) => { + if (n.id !== nodeId) return n; + + const existingResults = n.data.nodeExecutionResults || []; + const duplicateIndex = existingResults.findIndex( + (r) => r.node_exec_id === result.node_exec_id, + ); + + if (duplicateIndex !== -1) { + const oldResult = existingResults[duplicateIndex]; + const inputDataChanged = + JSON.stringify(oldResult.input_data) !== + JSON.stringify(result.input_data); + const outputDataChanged = + JSON.stringify(oldResult.output_data) !== + JSON.stringify(result.output_data); + + if (!inputDataChanged && !outputDataChanged) { + return n; + } + + const updatedResults = [...existingResults]; + updatedResults[duplicateIndex] = result; + + const recomputedAccumulatedInput = updatedResults.reduce( + (acc, r) => accumulateExecutionData(acc, r.input_data), + {} as Record, + ); + const recomputedAccumulatedOutput = updatedResults.reduce( + (acc, r) => accumulateExecutionData(acc, r.output_data), + {} as Record, + ); + + const mostRecentResult = updatedResults[updatedResults.length - 1]; + latestNodeInputData = { + ...latestNodeInputData, + [nodeId]: mostRecentResult.input_data, + }; + latestNodeOutputData = { + ...latestNodeOutputData, + [nodeId]: mostRecentResult.output_data, + }; + + accumulatedNodeInputData = { + ...accumulatedNodeInputData, + [nodeId]: recomputedAccumulatedInput, + }; + accumulatedNodeOutputData = { + ...accumulatedNodeOutputData, + [nodeId]: recomputedAccumulatedOutput, + }; + + return { + ...n, + data: { + ...n.data, + nodeExecutionResults: updatedResults, + }, + }; + } + + accumulatedNodeInputData = { + ...accumulatedNodeInputData, + [nodeId]: accumulateExecutionData( + accumulatedNodeInputData[nodeId] || {}, + result.input_data, + ), + }; + accumulatedNodeOutputData = { + ...accumulatedNodeOutputData, + [nodeId]: accumulateExecutionData( + accumulatedNodeOutputData[nodeId] || {}, + result.output_data, + ), + }; + + latestNodeInputData = { + ...latestNodeInputData, + [nodeId]: result.input_data, + }; + latestNodeOutputData = { + ...latestNodeOutputData, + [nodeId]: result.output_data, + }; + + return { + ...n, + data: { + ...n.data, + nodeExecutionResults: [...existingResults, result], + }, + }; + }); + + return { + nodes, + latestNodeInputData, + latestNodeOutputData, + accumulatedNodeInputData, + accumulatedNodeOutputData, + }; + }); + }, + getNodeExecutionResults: (nodeId: string) => { + return ( + get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResults || [] + ); + }, + getLatestNodeInputData: (nodeId: string) => { + return get().latestNodeInputData[nodeId]; + }, + getLatestNodeOutputData: (nodeId: string) => { + return get().latestNodeOutputData[nodeId]; + }, + getAccumulatedNodeInputData: (nodeId: string) => { + return get().accumulatedNodeInputData[nodeId] || {}; + }, + getAccumulatedNodeOutputData: (nodeId: string) => { + return get().accumulatedNodeOutputData[nodeId] || {}; + }, + getLatestNodeExecutionResult: (nodeId: string) => { + const results = + get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResults || + []; + return results.length > 0 ? results[results.length - 1] : undefined; + }, + clearAllNodeExecutionResults: () => { + set((state) => ({ + nodes: state.nodes.map((n) => ({ + ...n, + data: { + ...n.data, + nodeExecutionResults: [], + }, + })), + latestNodeInputData: {}, + latestNodeOutputData: {}, + accumulatedNodeInputData: {}, + accumulatedNodeOutputData: {}, + })); }, getNodeBlockUIType: (nodeId: string) => { return ( diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts new file mode 100644 index 0000000000..f0ec7e6c1c --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts @@ -0,0 +1,14 @@ +import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types"; + +export type NodeResolutionData = { + incompatibilities: IncompatibilityInfo; + pendingUpdate: { + input_schema: Record; + output_schema: Record; + }; + currentSchema: { + input_schema: Record; + output_schema: Record; + }; + pendingHardcodedValues: Record; +}; From 75ecc4de92281d72f256993989763b66a7acd8a5 Mon Sep 17 00:00:00 2001 From: Swifty Date: Mon, 26 Jan 2026 14:56:24 +0100 Subject: [PATCH 10/36] fix(backend): enforce block disabled flag on execution endpoints (#11839) ## Summary This PR adds security checks to prevent execution of disabled blocks across all block execution endpoints. - Add `disabled` flag check to main web API endpoint (`/api/blocks/{block_id}/execute`) - Add `disabled` flag check to external API endpoint (`/api/blocks/{block_id}/execute`) - Add `disabled` flag check to chat tool block execution Previously, block execution endpoints only checked if a block existed but did not verify the `disabled` flag, allowing any authenticated user to execute disabled blocks. ## Test plan - [x] Verify disabled blocks return 403 Forbidden on main API endpoint - [x] Verify disabled blocks return 403 Forbidden on external API endpoint - [x] Verify disabled blocks return error response in chat tool execution - [x] Verify enabled blocks continue to execute normally --- autogpt_platform/backend/backend/api/external/v1/routes.py | 2 ++ .../backend/backend/api/features/chat/tools/run_block.py | 5 +++++ autogpt_platform/backend/backend/api/features/v1.py | 2 ++ autogpt_platform/backend/backend/api/features/v1_test.py | 1 + 4 files changed, 10 insertions(+) diff --git a/autogpt_platform/backend/backend/api/external/v1/routes.py b/autogpt_platform/backend/backend/api/external/v1/routes.py index 58e15dc6a3..00933c1899 100644 --- a/autogpt_platform/backend/backend/api/external/v1/routes.py +++ b/autogpt_platform/backend/backend/api/external/v1/routes.py @@ -86,6 +86,8 @@ async def execute_graph_block( obj = backend.data.block.get_block(block_id) if not obj: raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.") + if obj.disabled: + raise HTTPException(status_code=403, detail=f"Block #{block_id} is disabled.") output = defaultdict(list) async for name, data in obj.execute(data): diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py b/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py index c29cc92556..0d233fcfec 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py @@ -179,6 +179,11 @@ class RunBlockTool(BaseTool): message=f"Block '{block_id}' not found", session_id=session_id, ) + if block.disabled: + return ErrorResponse( + message=f"Block '{block_id}' is disabled", + session_id=session_id, + ) logger.info(f"Executing block {block.name} ({block_id}) for user {user_id}") diff --git a/autogpt_platform/backend/backend/api/features/v1.py b/autogpt_platform/backend/backend/api/features/v1.py index 3a5dd3ec12..51789f9e2b 100644 --- a/autogpt_platform/backend/backend/api/features/v1.py +++ b/autogpt_platform/backend/backend/api/features/v1.py @@ -364,6 +364,8 @@ async def execute_graph_block( obj = get_block(block_id) if not obj: raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.") + if obj.disabled: + raise HTTPException(status_code=403, detail=f"Block #{block_id} is disabled.") user = await get_user_by_id(user_id) if not user: diff --git a/autogpt_platform/backend/backend/api/features/v1_test.py b/autogpt_platform/backend/backend/api/features/v1_test.py index a186d38810..d57ad49949 100644 --- a/autogpt_platform/backend/backend/api/features/v1_test.py +++ b/autogpt_platform/backend/backend/api/features/v1_test.py @@ -138,6 +138,7 @@ def test_execute_graph_block( """Test execute block endpoint""" # Mock block mock_block = Mock() + mock_block.disabled = False async def mock_execute(*args, **kwargs): yield "output1", {"data": "result1"} From fbc2da36e630a98c699f42dacb788b41432df780 Mon Sep 17 00:00:00 2001 From: Ubbe Date: Mon, 26 Jan 2026 22:54:19 +0700 Subject: [PATCH 11/36] fix(analytics): only try to init Posthog when on cloud (#11843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes šŸ—ļø This prevents Posthog from being initialised locally, where we should not be collecting analytics during local development. ## Checklist šŸ“‹ ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Run locally and test the above --- .../src/providers/posthog/posthog-provider.tsx | 15 +++++++++++---- .../frontend/src/services/environment/index.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx b/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx index 58ee2394ef..249d74596a 100644 --- a/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx +++ b/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx @@ -1,12 +1,15 @@ "use client"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; +import { environment } from "@/services/environment"; import { PostHogProvider as PHProvider } from "@posthog/react"; import { usePathname, useSearchParams } from "next/navigation"; import posthog from "posthog-js"; import { ReactNode, useEffect, useRef } from "react"; export function PostHogProvider({ children }: { children: ReactNode }) { + const isPostHogEnabled = environment.isPostHogEnabled(); + useEffect(() => { if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { @@ -19,15 +22,18 @@ export function PostHogProvider({ children }: { children: ReactNode }) { } }, []); + if (!isPostHogEnabled) return <>{children}; + return {children}; } export function PostHogUserTracker() { const { user, isUserLoading } = useSupabase(); const previousUserIdRef = useRef(null); + const isPostHogEnabled = environment.isPostHogEnabled(); useEffect(() => { - if (isUserLoading) return; + if (isUserLoading || !isPostHogEnabled) return; if (user) { if (previousUserIdRef.current !== user.id) { @@ -41,7 +47,7 @@ export function PostHogUserTracker() { posthog.reset(); previousUserIdRef.current = null; } - }, [user, isUserLoading]); + }, [user, isUserLoading, isPostHogEnabled]); return null; } @@ -49,16 +55,17 @@ export function PostHogUserTracker() { export function PostHogPageViewTracker() { const pathname = usePathname(); const searchParams = useSearchParams(); + const isPostHogEnabled = environment.isPostHogEnabled(); useEffect(() => { - if (pathname) { + if (pathname && isPostHogEnabled) { let url = window.origin + pathname; if (searchParams && searchParams.toString()) { url = url + `?${searchParams.toString()}`; } posthog.capture("$pageview", { $current_url: url }); } - }, [pathname, searchParams]); + }, [pathname, searchParams, isPostHogEnabled]); return null; } diff --git a/autogpt_platform/frontend/src/services/environment/index.ts b/autogpt_platform/frontend/src/services/environment/index.ts index cdd5b421b5..f19bc417e3 100644 --- a/autogpt_platform/frontend/src/services/environment/index.ts +++ b/autogpt_platform/frontend/src/services/environment/index.ts @@ -76,6 +76,13 @@ function getPreviewStealingDev() { return branch; } +function getPostHogCredentials() { + return { + key: process.env.NEXT_PUBLIC_POSTHOG_KEY, + host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + }; +} + function isProductionBuild() { return process.env.NODE_ENV === "production"; } @@ -116,6 +123,13 @@ function areFeatureFlagsEnabled() { return process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "enabled"; } +function isPostHogEnabled() { + const inCloud = isCloud(); + const key = process.env.NEXT_PUBLIC_POSTHOG_KEY; + const host = process.env.NEXT_PUBLIC_POSTHOG_HOST; + return inCloud && key && host; +} + export const environment = { // Generic getEnvironmentStr, @@ -128,6 +142,7 @@ export const environment = { getSupabaseUrl, getSupabaseAnonKey, getPreviewStealingDev, + getPostHogCredentials, // Assertions isServerSide, isClientSide, @@ -138,5 +153,6 @@ export const environment = { isCloud, isLocal, isVercelPreview, + isPostHogEnabled, areFeatureFlagsEnabled, }; From d5c0f5b2df67b5eeb31d32f2982a6ca5af739b49 Mon Sep 17 00:00:00 2001 From: Swifty Date: Mon, 26 Jan 2026 17:00:48 +0100 Subject: [PATCH 12/36] refactor(backend): remove page context from chat service (#11844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Background The chat service previously supported including page context (URL and content) in user messages. This functionality is being removed. ### Changes šŸ—ļø - Removed page context handling from `stream_chat_completion` in the chat service - User messages are now passed directly without URL/content context injection - Removed associated logging for page context ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verify chat functionality works without page context - [x] Confirm no regressions in basic chat message handling --- .../backend/backend/api/features/chat/service.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/chat/service.py b/autogpt_platform/backend/backend/api/features/chat/service.py index 43fb7b35a7..e10343fff6 100644 --- a/autogpt_platform/backend/backend/api/features/chat/service.py +++ b/autogpt_platform/backend/backend/api/features/chat/service.py @@ -237,18 +237,9 @@ async def stream_chat_completion( ) if message: - # Build message content with context if provided - message_content = message - if context and context.get("url") and context.get("content"): - context_text = f"Page URL: {context['url']}\n\nPage Content:\n{context['content']}\n\n---\n\nUser Message: {message}" - message_content = context_text - logger.info( - f"Including page context: URL={context['url']}, content_length={len(context['content'])}" - ) - session.messages.append( ChatMessage( - role="user" if is_user_message else "assistant", content=message_content + role="user" if is_user_message else "assistant", content=message ) ) logger.info( From 859f3f8c06a862abc068df7c3bd04f30a8a93325 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Tue, 27 Jan 2026 03:22:30 -0600 Subject: [PATCH 13/36] feat(frontend): implement clarification questions UI for agent generation (#11833) ## Summary Add interactive UI to collect user answers when the agent-generator service returns clarifying questions during agent creation/editing. Previously, when the backend asked clarifying questions, the frontend would just display them as text with no way for users to answer. This caused the chat to keep retrying without the necessary context. ## Changes - **ChatMessageData type**: Add `clarification_needed` variant with questions field - **ClarificationQuestionsWidget**: New component with interactive form to collect answers - **parseToolResponse**: Detect and parse `clarification_needed` responses from backend - **ChatMessage**: Render the widget when clarification is needed ## How It Works 1. User requests to create/edit agent 2. Backend returns `ClarificationNeededResponse` with list of questions 3. Frontend shows interactive form with text inputs for each question 4. User fills in answers and clicks "Submit Answers" 5. Answers are sent back as context to the tool 6. Backend receives full context and continues ## UI Features - Shows all questions with examples (if provided) - Input validation (all questions must be answered to submit) - Visual feedback (checkmarks when answered) - Numbered questions for clarity - Submit button disabled until all answered - Follows same design pattern as `credentials_needed` flow ## Related - Backend support for clarification was added in #11819 - Fixes the issue shown in the screenshot where users couldn't answer clarifying questions ## Test plan - [ ] Test creating agent that requires clarifying questions - [ ] Verify questions are displayed in interactive form - [ ] Verify all questions must be answered before submitting - [ ] Verify answers are sent back to backend as context - [ ] Verify agent creation continues with full context --- .../Chat/components/ChatContainer/helpers.ts | 17 ++ .../components/ChatMessage/ChatMessage.tsx | 25 +++ .../components/ChatMessage/useChatMessage.ts | 13 ++ .../ClarificationQuestionsWidget.tsx | 154 ++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget.tsx diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts index 9d51003a93..0edd1b411a 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts @@ -213,6 +213,23 @@ export function parseToolResponse( timestamp: timestamp || new Date(), }; } + if (responseType === "clarification_needed") { + return { + type: "clarification_needed", + toolName, + questions: + (parsedResult.questions as Array<{ + question: string; + keyword: string; + example?: string; + }>) || [], + message: + (parsedResult.message as string) || + "I need more information to proceed.", + sessionId: (parsedResult.session_id as string) || "", + timestamp: timestamp || new Date(), + }; + } if (responseType === "need_login") { return { type: "login_needed", diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx index a2827ce611..0fee33dbc0 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx @@ -14,6 +14,7 @@ import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessa import { AIChatBubble } from "../AIChatBubble/AIChatBubble"; import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget"; import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup"; +import { ClarificationQuestionsWidget } from "../ClarificationQuestionsWidget/ClarificationQuestionsWidget"; import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage"; import { MarkdownContent } from "../MarkdownContent/MarkdownContent"; import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage"; @@ -69,6 +70,7 @@ export function ChatMessage({ isToolResponse, isLoginNeeded, isCredentialsNeeded, + isClarificationNeeded, } = useChatMessage(message); const displayContent = getDisplayContent(message, isUser); @@ -96,6 +98,18 @@ export function ChatMessage({ } } + function handleClarificationAnswers(answers: Record) { + if (onSendMessage) { + const contextMessage = Object.entries(answers) + .map(([keyword, answer]) => `${keyword}: ${answer}`) + .join("\n"); + + onSendMessage( + `I have the answers to your questions:\n\n${contextMessage}\n\nPlease proceed with creating the agent.`, + ); + } + } + const handleCopy = useCallback( async function handleCopy() { if (message.type !== "message") return; @@ -141,6 +155,17 @@ export function ChatMessage({ ); } + if (isClarificationNeeded && message.type === "clarification_needed") { + return ( + + ); + } + // Render login needed messages if (isLoginNeeded && message.type === "login_needed") { // If user is already logged in, show success message instead of auth prompt diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/useChatMessage.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/useChatMessage.ts index 5ee61bc554..142b140c8b 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/useChatMessage.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/useChatMessage.ts @@ -91,6 +91,18 @@ export type ChatMessageData = credentialsSchema?: Record; message: string; timestamp?: string | Date; + } + | { + type: "clarification_needed"; + toolName: string; + questions: Array<{ + question: string; + keyword: string; + example?: string; + }>; + message: string; + sessionId: string; + timestamp?: string | Date; }; export function useChatMessage(message: ChatMessageData) { @@ -111,5 +123,6 @@ export function useChatMessage(message: ChatMessageData) { isAgentCarousel: message.type === "agent_carousel", isExecutionStarted: message.type === "execution_started", isInputsNeeded: message.type === "inputs_needed", + isClarificationNeeded: message.type === "clarification_needed", }; } diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget.tsx new file mode 100644 index 0000000000..b2d3608254 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ClarificationQuestionsWidget/ClarificationQuestionsWidget.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { Button } from "@/components/atoms/Button/Button"; +import { Card } from "@/components/atoms/Card/Card"; +import { Input } from "@/components/atoms/Input/Input"; +import { Text } from "@/components/atoms/Text/Text"; +import { cn } from "@/lib/utils"; +import { CheckCircleIcon, QuestionIcon } from "@phosphor-icons/react"; +import { useState } from "react"; + +export interface ClarifyingQuestion { + question: string; + keyword: string; + example?: string; +} + +interface Props { + questions: ClarifyingQuestion[]; + message: string; + onSubmitAnswers: (answers: Record) => void; + onCancel?: () => void; + className?: string; +} + +export function ClarificationQuestionsWidget({ + questions, + message, + onSubmitAnswers, + onCancel, + className, +}: Props) { + const [answers, setAnswers] = useState>({}); + + function handleAnswerChange(keyword: string, value: string) { + setAnswers((prev) => ({ ...prev, [keyword]: value })); + } + + function handleSubmit() { + // Check if all questions are answered + const allAnswered = questions.every((q) => answers[q.keyword]?.trim()); + if (!allAnswered) { + return; + } + onSubmitAnswers(answers); + } + + const allAnswered = questions.every((q) => answers[q.keyword]?.trim()); + + return ( +
+
+
+
+ +
+
+ +
+ +
+ + I need more information + + + {message} + +
+ +
+ {questions.map((q, index) => { + const isAnswered = !!answers[q.keyword]?.trim(); + + return ( +
+
+ {isAnswered ? ( + + ) : ( +
+ {index + 1} +
+ )} +
+ + {q.question} + + {q.example && ( + + Example: {q.example} + + )} + + handleAnswerChange(q.keyword, e.target.value) + } + /> +
+
+
+ ); + })} +
+ +
+ + {onCancel && ( + + )} +
+
+
+
+
+ ); +} From bab436231a1e330e4213cca0993ad5dbcdc2efea Mon Sep 17 00:00:00 2001 From: Swifty Date: Tue, 27 Jan 2026 13:07:42 +0100 Subject: [PATCH 14/36] refactor(backend): remove Langfuse tracing from chat system (#11829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are removing Langfuse tracing from the chat/copilot system in favor of using OpenRouter's broadcast feature, which keeps our codebase simpler. Langfuse prompt management is retained for fetching system prompts. ### Changes šŸ—ļø **Removed Langfuse tracing:** - Removed `@observe` decorators from all 11 chat tool files - Removed `langfuse.openai` wrapper (now using standard `openai` client) - Removed `start_as_current_observation` and `propagate_attributes` context managers from `service.py` - Removed `update_current_trace()`, `update_current_span()`, `span.update()` calls **Retained Langfuse prompt management:** - `langfuse.get_prompt()` for fetching system prompts - `_is_langfuse_configured()` check for prompt availability - Configuration for `langfuse_prompt_name` **Files modified:** - `backend/api/features/chat/service.py` - `backend/api/features/chat/tools/*.py` (11 tool files) ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verified `poetry run format` passes - [x] Verified no `@observe` decorators remain in chat tools - [x] Verified Langfuse prompt fetching is still functional (code preserved) --- .../backend/api/features/chat/service.py | 609 ++++++++---------- .../features/chat/tools/add_understanding.py | 3 - .../api/features/chat/tools/agent_output.py | 2 - .../api/features/chat/tools/create_agent.py | 3 - .../api/features/chat/tools/edit_agent.py | 3 - .../api/features/chat/tools/find_agent.py | 3 - .../api/features/chat/tools/find_block.py | 2 - .../features/chat/tools/find_library_agent.py | 3 - .../api/features/chat/tools/get_doc_page.py | 3 - .../api/features/chat/tools/run_agent.py | 2 - .../api/features/chat/tools/run_block.py | 3 - .../api/features/chat/tools/search_docs.py | 2 - 12 files changed, 282 insertions(+), 356 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/chat/service.py b/autogpt_platform/backend/backend/api/features/chat/service.py index e10343fff6..3976cd5f38 100644 --- a/autogpt_platform/backend/backend/api/features/chat/service.py +++ b/autogpt_platform/backend/backend/api/features/chat/service.py @@ -5,9 +5,9 @@ from asyncio import CancelledError from collections.abc import AsyncGenerator from typing import Any +import openai import orjson -from langfuse import get_client, propagate_attributes -from langfuse.openai import openai # type: ignore +from langfuse import get_client from openai import ( APIConnectionError, APIError, @@ -299,347 +299,302 @@ async def stream_chat_completion( # Build system prompt with business understanding system_prompt, understanding = await _build_system_prompt(user_id) - # Create Langfuse trace for this LLM call (each call gets its own trace, grouped by session_id) - # Using v3 SDK: start_observation creates a root span, update_trace sets trace-level attributes - input = message - if not message and tool_call_response: - input = tool_call_response + # Initialize variables for streaming + assistant_response = ChatMessage( + role="assistant", + content="", + ) + accumulated_tool_calls: list[dict[str, Any]] = [] + has_saved_assistant_message = False + has_appended_streaming_message = False + last_cache_time = 0.0 + last_cache_content_len = 0 - langfuse = get_client() - with langfuse.start_as_current_observation( - as_type="span", - name="user-copilot-request", - input=input, - ) as span: - with propagate_attributes( - session_id=session_id, - user_id=user_id, - tags=["copilot"], - metadata={ - "users_information": format_understanding_for_prompt(understanding)[ - :200 - ] # langfuse only accepts upto to 200 chars - }, + has_yielded_end = False + has_yielded_error = False + has_done_tool_call = False + has_received_text = False + text_streaming_ended = False + tool_response_messages: list[ChatMessage] = [] + should_retry = False + + # Generate unique IDs for AI SDK protocol + import uuid as uuid_module + + message_id = str(uuid_module.uuid4()) + text_block_id = str(uuid_module.uuid4()) + + # Yield message start + yield StreamStart(messageId=message_id) + + try: + async for chunk in _stream_chat_chunks( + session=session, + tools=tools, + system_prompt=system_prompt, + text_block_id=text_block_id, ): - # Initialize variables that will be used in finally block (must be defined before try) - assistant_response = ChatMessage( - role="assistant", - content="", - ) - accumulated_tool_calls: list[dict[str, Any]] = [] - has_saved_assistant_message = False - has_appended_streaming_message = False - last_cache_time = 0.0 - last_cache_content_len = 0 - - # Wrap main logic in try/finally to ensure Langfuse observations are always ended - has_yielded_end = False - has_yielded_error = False - has_done_tool_call = False - has_received_text = False - text_streaming_ended = False - tool_response_messages: list[ChatMessage] = [] - should_retry = False - - # Generate unique IDs for AI SDK protocol - import uuid as uuid_module - - message_id = str(uuid_module.uuid4()) - text_block_id = str(uuid_module.uuid4()) - - # Yield message start - yield StreamStart(messageId=message_id) - - try: - async for chunk in _stream_chat_chunks( - session=session, - tools=tools, - system_prompt=system_prompt, - text_block_id=text_block_id, + if isinstance(chunk, StreamTextStart): + # Emit text-start before first text delta + if not has_received_text: + yield chunk + elif isinstance(chunk, StreamTextDelta): + delta = chunk.delta or "" + assert assistant_response.content is not None + assistant_response.content += delta + has_received_text = True + if not has_appended_streaming_message: + session.messages.append(assistant_response) + has_appended_streaming_message = True + current_time = time.monotonic() + content_len = len(assistant_response.content) + if ( + current_time - last_cache_time >= 1.0 + and content_len > last_cache_content_len ): - - if isinstance(chunk, StreamTextStart): - # Emit text-start before first text delta - if not has_received_text: - yield chunk - elif isinstance(chunk, StreamTextDelta): - delta = chunk.delta or "" - assert assistant_response.content is not None - assistant_response.content += delta - has_received_text = True - if not has_appended_streaming_message: - session.messages.append(assistant_response) - has_appended_streaming_message = True - current_time = time.monotonic() - content_len = len(assistant_response.content) - if ( - current_time - last_cache_time >= 1.0 - and content_len > last_cache_content_len - ): - try: - await cache_chat_session(session) - except Exception as e: - logger.warning( - f"Failed to cache partial session {session.session_id}: {e}" - ) - last_cache_time = current_time - last_cache_content_len = content_len - yield chunk - elif isinstance(chunk, StreamTextEnd): - # Emit text-end after text completes - if has_received_text and not text_streaming_ended: - text_streaming_ended = True - if assistant_response.content: - logger.warn( - f"StreamTextEnd: Attempting to set output {assistant_response.content}" - ) - span.update_trace(output=assistant_response.content) - span.update(output=assistant_response.content) - yield chunk - elif isinstance(chunk, StreamToolInputStart): - # Emit text-end before first tool call, but only if we've received text - if has_received_text and not text_streaming_ended: - yield StreamTextEnd(id=text_block_id) - text_streaming_ended = True - yield chunk - elif isinstance(chunk, StreamToolInputAvailable): - # Accumulate tool calls in OpenAI format - accumulated_tool_calls.append( - { - "id": chunk.toolCallId, - "type": "function", - "function": { - "name": chunk.toolName, - "arguments": orjson.dumps(chunk.input).decode( - "utf-8" - ), - }, - } - ) - elif isinstance(chunk, StreamToolOutputAvailable): - result_content = ( - chunk.output - if isinstance(chunk.output, str) - else orjson.dumps(chunk.output).decode("utf-8") - ) - tool_response_messages.append( - ChatMessage( - role="tool", - content=result_content, - tool_call_id=chunk.toolCallId, - ) - ) - has_done_tool_call = True - # Track if any tool execution failed - if not chunk.success: - logger.warning( - f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed" - ) - yield chunk - elif isinstance(chunk, StreamFinish): - if not has_done_tool_call: - # Emit text-end before finish if we received text but haven't closed it - if has_received_text and not text_streaming_ended: - yield StreamTextEnd(id=text_block_id) - text_streaming_ended = True - - # Save assistant message before yielding finish to ensure it's persisted - # even if client disconnects immediately after receiving StreamFinish - if not has_saved_assistant_message: - messages_to_save_early: list[ChatMessage] = [] - if accumulated_tool_calls: - assistant_response.tool_calls = ( - accumulated_tool_calls - ) - if not has_appended_streaming_message and ( - assistant_response.content - or assistant_response.tool_calls - ): - messages_to_save_early.append(assistant_response) - messages_to_save_early.extend(tool_response_messages) - - if messages_to_save_early: - session.messages.extend(messages_to_save_early) - logger.info( - f"Saving assistant message before StreamFinish: " - f"content_len={len(assistant_response.content or '')}, " - f"tool_calls={len(assistant_response.tool_calls or [])}, " - f"tool_responses={len(tool_response_messages)}" - ) - if ( - messages_to_save_early - or has_appended_streaming_message - ): - await upsert_chat_session(session) - has_saved_assistant_message = True - - has_yielded_end = True - yield chunk - elif isinstance(chunk, StreamError): - has_yielded_error = True - yield chunk - elif isinstance(chunk, StreamUsage): - session.usage.append( - Usage( - prompt_tokens=chunk.promptTokens, - completion_tokens=chunk.completionTokens, - total_tokens=chunk.totalTokens, - ) - ) - else: - logger.error( - f"Unknown chunk type: {type(chunk)}", exc_info=True - ) - if assistant_response.content: - langfuse.update_current_trace(output=assistant_response.content) - langfuse.update_current_span(output=assistant_response.content) - elif tool_response_messages: - langfuse.update_current_trace(output=str(tool_response_messages)) - langfuse.update_current_span(output=str(tool_response_messages)) - - except CancelledError: - if not has_saved_assistant_message: - if accumulated_tool_calls: - assistant_response.tool_calls = accumulated_tool_calls - if assistant_response.content: - assistant_response.content = ( - f"{assistant_response.content}\n\n[interrupted]" - ) - else: - assistant_response.content = "[interrupted]" - if not has_appended_streaming_message: - session.messages.append(assistant_response) - if tool_response_messages: - session.messages.extend(tool_response_messages) try: - await upsert_chat_session(session) + await cache_chat_session(session) except Exception as e: logger.warning( - f"Failed to save interrupted session {session.session_id}: {e}" + f"Failed to cache partial session {session.session_id}: {e}" ) - raise - except Exception as e: - logger.error(f"Error during stream: {e!s}", exc_info=True) - - # Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.) - is_retryable = isinstance( - e, (orjson.JSONDecodeError, KeyError, TypeError) - ) - - if is_retryable and retry_count < config.max_retries: - logger.info( - f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}" - ) - should_retry = True - else: - # Non-retryable error or max retries exceeded - # Save any partial progress before reporting error - messages_to_save: list[ChatMessage] = [] - - # Add assistant message if it has content or tool calls - if accumulated_tool_calls: - assistant_response.tool_calls = accumulated_tool_calls - if not has_appended_streaming_message and ( - assistant_response.content or assistant_response.tool_calls - ): - messages_to_save.append(assistant_response) - - # Add tool response messages after assistant message - messages_to_save.extend(tool_response_messages) - - if not has_saved_assistant_message: - if messages_to_save: - session.messages.extend(messages_to_save) - if messages_to_save or has_appended_streaming_message: - await upsert_chat_session(session) - - if not has_yielded_error: - error_message = str(e) - if not is_retryable: - error_message = f"Non-retryable error: {error_message}" - elif retry_count >= config.max_retries: - error_message = f"Max retries ({config.max_retries}) exceeded: {error_message}" - - error_response = StreamError(errorText=error_message) - yield error_response - if not has_yielded_end: - yield StreamFinish() - return - - # Handle retry outside of exception handler to avoid nesting - if should_retry and retry_count < config.max_retries: - logger.info( - f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}" - ) - async for chunk in stream_chat_completion( - session_id=session.session_id, - user_id=user_id, - retry_count=retry_count + 1, - session=session, - context=context, - ): + last_cache_time = current_time + last_cache_content_len = content_len + yield chunk + elif isinstance(chunk, StreamTextEnd): + # Emit text-end after text completes + if has_received_text and not text_streaming_ended: + text_streaming_ended = True yield chunk - return # Exit after retry to avoid double-saving in finally block + elif isinstance(chunk, StreamToolInputStart): + # Emit text-end before first tool call, but only if we've received text + if has_received_text and not text_streaming_ended: + yield StreamTextEnd(id=text_block_id) + text_streaming_ended = True + yield chunk + elif isinstance(chunk, StreamToolInputAvailable): + # Accumulate tool calls in OpenAI format + accumulated_tool_calls.append( + { + "id": chunk.toolCallId, + "type": "function", + "function": { + "name": chunk.toolName, + "arguments": orjson.dumps(chunk.input).decode("utf-8"), + }, + } + ) + yield chunk + elif isinstance(chunk, StreamToolOutputAvailable): + result_content = ( + chunk.output + if isinstance(chunk.output, str) + else orjson.dumps(chunk.output).decode("utf-8") + ) + tool_response_messages.append( + ChatMessage( + role="tool", + content=result_content, + tool_call_id=chunk.toolCallId, + ) + ) + has_done_tool_call = True + # Track if any tool execution failed + if not chunk.success: + logger.warning( + f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed" + ) + yield chunk + elif isinstance(chunk, StreamFinish): + if not has_done_tool_call: + # Emit text-end before finish if we received text but haven't closed it + if has_received_text and not text_streaming_ended: + yield StreamTextEnd(id=text_block_id) + text_streaming_ended = True + + # Save assistant message before yielding finish to ensure it's persisted + # even if client disconnects immediately after receiving StreamFinish + if not has_saved_assistant_message: + messages_to_save_early: list[ChatMessage] = [] + if accumulated_tool_calls: + assistant_response.tool_calls = accumulated_tool_calls + if not has_appended_streaming_message and ( + assistant_response.content or assistant_response.tool_calls + ): + messages_to_save_early.append(assistant_response) + messages_to_save_early.extend(tool_response_messages) + + if messages_to_save_early: + session.messages.extend(messages_to_save_early) + logger.info( + f"Saving assistant message before StreamFinish: " + f"content_len={len(assistant_response.content or '')}, " + f"tool_calls={len(assistant_response.tool_calls or [])}, " + f"tool_responses={len(tool_response_messages)}" + ) + if messages_to_save_early or has_appended_streaming_message: + await upsert_chat_session(session) + has_saved_assistant_message = True + + has_yielded_end = True + yield chunk + elif isinstance(chunk, StreamError): + has_yielded_error = True + yield chunk + elif isinstance(chunk, StreamUsage): + session.usage.append( + Usage( + prompt_tokens=chunk.promptTokens, + completion_tokens=chunk.completionTokens, + total_tokens=chunk.totalTokens, + ) + ) + else: + logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True) + + except CancelledError: + if not has_saved_assistant_message: + if accumulated_tool_calls: + assistant_response.tool_calls = accumulated_tool_calls + if assistant_response.content: + assistant_response.content = ( + f"{assistant_response.content}\n\n[interrupted]" + ) + else: + assistant_response.content = "[interrupted]" + if not has_appended_streaming_message: + session.messages.append(assistant_response) + if tool_response_messages: + session.messages.extend(tool_response_messages) + try: + await upsert_chat_session(session) + except Exception as e: + logger.warning( + f"Failed to save interrupted session {session.session_id}: {e}" + ) + raise + except Exception as e: + logger.error(f"Error during stream: {e!s}", exc_info=True) + + # Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.) + is_retryable = isinstance(e, (orjson.JSONDecodeError, KeyError, TypeError)) + + if is_retryable and retry_count < config.max_retries: + logger.info( + f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}" + ) + should_retry = True + else: + # Non-retryable error or max retries exceeded + # Save any partial progress before reporting error + messages_to_save: list[ChatMessage] = [] + + # Add assistant message if it has content or tool calls + if accumulated_tool_calls: + assistant_response.tool_calls = accumulated_tool_calls + if not has_appended_streaming_message and ( + assistant_response.content or assistant_response.tool_calls + ): + messages_to_save.append(assistant_response) + + # Add tool response messages after assistant message + messages_to_save.extend(tool_response_messages) - # Normal completion path - save session and handle tool call continuation - # Only save if we haven't already saved when StreamFinish was received if not has_saved_assistant_message: - logger.info( - f"Normal completion path: session={session.session_id}, " - f"current message_count={len(session.messages)}" - ) - - # Build the messages list in the correct order - messages_to_save: list[ChatMessage] = [] - - # Add assistant message with tool_calls if any - if accumulated_tool_calls: - assistant_response.tool_calls = accumulated_tool_calls - logger.info( - f"Added {len(accumulated_tool_calls)} tool calls to assistant message" - ) - if not has_appended_streaming_message and ( - assistant_response.content or assistant_response.tool_calls - ): - messages_to_save.append(assistant_response) - logger.info( - f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}" - ) - - # Add tool response messages after assistant message - messages_to_save.extend(tool_response_messages) - logger.info( - f"Saving {len(tool_response_messages)} tool response messages, " - f"total_to_save={len(messages_to_save)}" - ) - if messages_to_save: session.messages.extend(messages_to_save) - logger.info( - f"Extended session messages, new message_count={len(session.messages)}" - ) if messages_to_save or has_appended_streaming_message: await upsert_chat_session(session) - else: - logger.info( - "Assistant message already saved when StreamFinish was received, " - "skipping duplicate save" - ) - # If we did a tool call, stream the chat completion again to get the next response - if has_done_tool_call: - logger.info( - "Tool call executed, streaming chat completion again to get assistant response" - ) - async for chunk in stream_chat_completion( - session_id=session.session_id, - user_id=user_id, - session=session, # Pass session object to avoid Redis refetch - context=context, - tool_call_response=str(tool_response_messages), - ): - yield chunk + if not has_yielded_error: + error_message = str(e) + if not is_retryable: + error_message = f"Non-retryable error: {error_message}" + elif retry_count >= config.max_retries: + error_message = ( + f"Max retries ({config.max_retries}) exceeded: {error_message}" + ) + + error_response = StreamError(errorText=error_message) + yield error_response + if not has_yielded_end: + yield StreamFinish() + return + + # Handle retry outside of exception handler to avoid nesting + if should_retry and retry_count < config.max_retries: + logger.info( + f"Retrying stream_chat_completion for session {session_id}, attempt {retry_count + 1}" + ) + async for chunk in stream_chat_completion( + session_id=session.session_id, + user_id=user_id, + retry_count=retry_count + 1, + session=session, + context=context, + ): + yield chunk + return # Exit after retry to avoid double-saving in finally block + + # Normal completion path - save session and handle tool call continuation + # Only save if we haven't already saved when StreamFinish was received + if not has_saved_assistant_message: + logger.info( + f"Normal completion path: session={session.session_id}, " + f"current message_count={len(session.messages)}" + ) + + # Build the messages list in the correct order + messages_to_save: list[ChatMessage] = [] + + # Add assistant message with tool_calls if any + if accumulated_tool_calls: + assistant_response.tool_calls = accumulated_tool_calls + logger.info( + f"Added {len(accumulated_tool_calls)} tool calls to assistant message" + ) + if not has_appended_streaming_message and ( + assistant_response.content or assistant_response.tool_calls + ): + messages_to_save.append(assistant_response) + logger.info( + f"Saving assistant message with content_len={len(assistant_response.content or '')}, tool_calls={len(assistant_response.tool_calls or [])}" + ) + + # Add tool response messages after assistant message + messages_to_save.extend(tool_response_messages) + logger.info( + f"Saving {len(tool_response_messages)} tool response messages, " + f"total_to_save={len(messages_to_save)}" + ) + + if messages_to_save: + session.messages.extend(messages_to_save) + logger.info( + f"Extended session messages, new message_count={len(session.messages)}" + ) + if messages_to_save or has_appended_streaming_message: + await upsert_chat_session(session) + else: + logger.info( + "Assistant message already saved when StreamFinish was received, " + "skipping duplicate save" + ) + + # If we did a tool call, stream the chat completion again to get the next response + if has_done_tool_call: + logger.info( + "Tool call executed, streaming chat completion again to get assistant response" + ) + async for chunk in stream_chat_completion( + session_id=session.session_id, + user_id=user_id, + session=session, # Pass session object to avoid Redis refetch + context=context, + tool_call_response=str(tool_response_messages), + ): + yield chunk # Retry configuration for OpenAI API calls diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/add_understanding.py b/autogpt_platform/backend/backend/api/features/chat/tools/add_understanding.py index bd93f0e2a6..fe3d5e8984 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/add_understanding.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/add_understanding.py @@ -3,8 +3,6 @@ import logging from typing import Any -from langfuse import observe - from backend.api.features.chat.model import ChatSession from backend.data.understanding import ( BusinessUnderstandingInput, @@ -61,7 +59,6 @@ and automations for the user's specific needs.""" """Requires authentication to store user-specific data.""" return True - @observe(as_type="tool", name="add_understanding") async def _execute( self, user_id: str | None, diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_output.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_output.py index 00c6d8499b..457e4a4f9b 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_output.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_output.py @@ -5,7 +5,6 @@ import re from datetime import datetime, timedelta, timezone from typing import Any -from langfuse import observe from pydantic import BaseModel, field_validator from backend.api.features.chat.model import ChatSession @@ -329,7 +328,6 @@ class AgentOutputTool(BaseTool): total_executions=len(available_executions) if available_executions else 1, ) - @observe(as_type="tool", name="view_agent_output") async def _execute( self, user_id: str | None, diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py index 5a3c44fb94..6469cc4442 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py @@ -3,8 +3,6 @@ import logging from typing import Any -from langfuse import observe - from backend.api.features.chat.model import ChatSession from .agent_generator import ( @@ -75,7 +73,6 @@ class CreateAgentTool(BaseTool): "required": ["description"], } - @observe(as_type="tool", name="create_agent") async def _execute( self, user_id: str | None, diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py index 777c39a254..df1e4a9c3e 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py @@ -3,8 +3,6 @@ import logging from typing import Any -from langfuse import observe - from backend.api.features.chat.model import ChatSession from .agent_generator import ( @@ -81,7 +79,6 @@ class EditAgentTool(BaseTool): "required": ["agent_id", "changes"], } - @observe(as_type="tool", name="edit_agent") async def _execute( self, user_id: str | None, diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/find_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/find_agent.py index f231ef4484..477522757d 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/find_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/find_agent.py @@ -2,8 +2,6 @@ from typing import Any -from langfuse import observe - from backend.api.features.chat.model import ChatSession from .agent_search import search_agents @@ -37,7 +35,6 @@ class FindAgentTool(BaseTool): "required": ["query"], } - @observe(as_type="tool", name="find_agent") async def _execute( self, user_id: str | None, session: ChatSession, **kwargs ) -> ToolResponseBase: diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/find_block.py b/autogpt_platform/backend/backend/api/features/chat/tools/find_block.py index fc20fdfc4a..a5e66f0a1c 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/find_block.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/find_block.py @@ -1,7 +1,6 @@ import logging from typing import Any -from langfuse import observe from prisma.enums import ContentType from backend.api.features.chat.model import ChatSession @@ -56,7 +55,6 @@ class FindBlockTool(BaseTool): def requires_auth(self) -> bool: return True - @observe(as_type="tool", name="find_block") async def _execute( self, user_id: str | None, diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/find_library_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/find_library_agent.py index d9b5edfa9b..108fba75ae 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/find_library_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/find_library_agent.py @@ -2,8 +2,6 @@ from typing import Any -from langfuse import observe - from backend.api.features.chat.model import ChatSession from .agent_search import search_agents @@ -43,7 +41,6 @@ class FindLibraryAgentTool(BaseTool): def requires_auth(self) -> bool: return True - @observe(as_type="tool", name="find_library_agent") async def _execute( self, user_id: str | None, session: ChatSession, **kwargs ) -> ToolResponseBase: diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/get_doc_page.py b/autogpt_platform/backend/backend/api/features/chat/tools/get_doc_page.py index b2fdcccfcd..7040cd7db5 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/get_doc_page.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/get_doc_page.py @@ -4,8 +4,6 @@ import logging from pathlib import Path from typing import Any -from langfuse import observe - from backend.api.features.chat.model import ChatSession from backend.api.features.chat.tools.base import BaseTool from backend.api.features.chat.tools.models import ( @@ -73,7 +71,6 @@ class GetDocPageTool(BaseTool): url_path = path.rsplit(".", 1)[0] if "." in path else path return f"{DOCS_BASE_URL}/{url_path}" - @observe(as_type="tool", name="get_doc_page") async def _execute( self, user_id: str | None, diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/run_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/run_agent.py index 88d432a797..a7fa65348a 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/run_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/run_agent.py @@ -3,7 +3,6 @@ import logging from typing import Any -from langfuse import observe from pydantic import BaseModel, Field, field_validator from backend.api.features.chat.config import ChatConfig @@ -159,7 +158,6 @@ class RunAgentTool(BaseTool): """All operations require authentication.""" return True - @observe(as_type="tool", name="run_agent") async def _execute( self, user_id: str | None, diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py b/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py index 0d233fcfec..3f57236564 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py @@ -4,8 +4,6 @@ import logging from collections import defaultdict from typing import Any -from langfuse import observe - from backend.api.features.chat.model import ChatSession from backend.data.block import get_block from backend.data.execution import ExecutionContext @@ -130,7 +128,6 @@ class RunBlockTool(BaseTool): return matched_credentials, missing_credentials - @observe(as_type="tool", name="run_block") async def _execute( self, user_id: str | None, diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/search_docs.py b/autogpt_platform/backend/backend/api/features/chat/tools/search_docs.py index 4903230b40..edb0c0de1e 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/search_docs.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/search_docs.py @@ -3,7 +3,6 @@ import logging from typing import Any -from langfuse import observe from prisma.enums import ContentType from backend.api.features.chat.model import ChatSession @@ -88,7 +87,6 @@ class SearchDocsTool(BaseTool): url_path = path.rsplit(".", 1)[0] if "." in path else path return f"{DOCS_BASE_URL}/{url_path}" - @observe(as_type="tool", name="search_docs") async def _execute( self, user_id: str | None, From 91c78968599a8bbe9ac15925a798a4023fb5acf9 Mon Sep 17 00:00:00 2001 From: Bently Date: Tue, 27 Jan 2026 15:37:17 +0100 Subject: [PATCH 15/36] fix(backend): implement context window management for long chat sessions (#11848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes šŸ—ļø Implements automatic context window management to prevent chat failures when conversations exceed token limits. ### Problem - **Issue**: [SECRT-1800] Long chat conversations stop working when context grows beyond model limits (~113k tokens observed) - **Root Cause**: Chat service sends ALL messages to LLM without token-aware compression, eventually exceeding Claude Opus 4.5's 200k context window ### Solution Implements a sliding window with summarization strategy: 1. Monitors token count before sending to LLM (triggers at 120k tokens) 2. Keeps last 15 messages completely intact (preserves recent conversation flow) 3. Summarizes older messages using gpt-4o-mini (fast & cheap) 4. Rebuilds context: `[system_prompt] + [summary] + [recent_15_messages]` 5. Full history preserved in database (only compresses when sending to LLM) ### Changes Made - **Added** `_summarize_messages()` helper function to create concise summaries using gpt-4o-mini - **Modified** `_stream_chat_chunks()` to implement token counting and conditional summarization - **Integrated** existing `estimate_token_count()` utility for accurate token measurement - **Added** graceful fallback - continues with original messages if summarization fails ## Motivation and Context šŸŽÆ Without context management, users with long chat sessions (250+ messages) experience: - Complete chat failure when hitting 200k token limit - Lost conversation context - Poor user experience This fix enables: - āœ… Unlimited conversation length - āœ… Transparent operation (no UX changes) - āœ… Preserved conversation quality (recent messages intact) - āœ… Cost-efficient (~$0.0001 per summarization) ## Testing 🧪 ### Expected Behavior - Conversations < 120k tokens: No change (normal operation) - Conversations > 120k tokens: - Log message: `Context summarized: {tokens} tokens, kept last 15 messages + summary` - Chat continues working smoothly - Recent context remains intact ### How to Verify 1. Start a chat session in copilot 2. Send 250-600 messages (or 50+ with large code blocks) 3. Check logs for "Context summarized:" message 4. Verify chat continues working without errors 5. Verify conversation quality remains good ## Checklist āœ… - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have tested my changes and verified they work as expected --- .../backend/api/features/chat/service.py | 392 +++++++++++++++++- 1 file changed, 390 insertions(+), 2 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/chat/service.py b/autogpt_platform/backend/backend/api/features/chat/service.py index 3976cd5f38..f8336b9107 100644 --- a/autogpt_platform/backend/backend/api/features/chat/service.py +++ b/autogpt_platform/backend/backend/api/features/chat/service.py @@ -628,6 +628,101 @@ def _is_region_blocked_error(error: Exception) -> bool: return "not available in your region" in str(error).lower() +async def _summarize_messages( + messages: list, + model: str, + api_key: str | None = None, + base_url: str | None = None, + timeout: float = 30.0, +) -> str: + """Summarize a list of messages into concise context. + + Uses the same model as the chat for higher quality summaries. + + Args: + messages: List of message dicts to summarize + model: Model to use for summarization (same as chat model) + api_key: API key for OpenAI client + base_url: Base URL for OpenAI client + timeout: Request timeout in seconds (default: 30.0) + + Returns: + Summarized text + """ + # Format messages for summarization + conversation = [] + for msg in messages: + role = msg.get("role", "") + content = msg.get("content", "") + # Include user, assistant, and tool messages (tool outputs are important context) + if content and role in ("user", "assistant", "tool"): + conversation.append(f"{role.upper()}: {content}") + + conversation_text = "\n\n".join(conversation) + + # Handle empty conversation + if not conversation_text: + return "No conversation history available." + + # Truncate conversation to fit within summarization model's context + # gpt-4o-mini has 128k context, but we limit to ~25k tokens (~100k chars) for safety + MAX_CHARS = 100_000 + if len(conversation_text) > MAX_CHARS: + conversation_text = conversation_text[:MAX_CHARS] + "\n\n[truncated]" + + # Call LLM to summarize + import openai + + summarization_client = openai.AsyncOpenAI( + api_key=api_key, base_url=base_url, timeout=timeout + ) + + response = await summarization_client.chat.completions.create( + model=model, + messages=[ + { + "role": "system", + "content": ( + "Create a detailed summary of the conversation so far. " + "This summary will be used as context when continuing the conversation.\n\n" + "Before writing the summary, analyze each message chronologically to identify:\n" + "- User requests and their explicit goals\n" + "- Your approach and key decisions made\n" + "- Technical specifics (file names, tool outputs, function signatures)\n" + "- Errors encountered and resolutions applied\n\n" + "You MUST include ALL of the following sections:\n\n" + "## 1. Primary Request and Intent\n" + "The user's explicit goals and what they are trying to accomplish.\n\n" + "## 2. Key Technical Concepts\n" + "Technologies, frameworks, tools, and patterns being used or discussed.\n\n" + "## 3. Files and Resources Involved\n" + "Specific files examined or modified, with relevant snippets and identifiers.\n\n" + "## 4. Errors and Fixes\n" + "Problems encountered, error messages, and their resolutions. " + "Include any user feedback on fixes.\n\n" + "## 5. Problem Solving\n" + "Issues that have been resolved and how they were addressed.\n\n" + "## 6. All User Messages\n" + "A complete list of all user inputs (excluding tool outputs) to preserve their exact requests.\n\n" + "## 7. Pending Tasks\n" + "Work items the user explicitly requested that have not yet been completed.\n\n" + "## 8. Current Work\n" + "Precise description of what was being worked on most recently, including relevant context.\n\n" + "## 9. Next Steps\n" + "What should happen next, aligned with the user's most recent requests. " + "Include verbatim quotes of recent instructions if relevant." + ), + }, + {"role": "user", "content": f"Summarize:\n\n{conversation_text}"}, + ], + max_tokens=1500, + temperature=0.3, + ) + + summary = response.choices[0].message.content + return summary or "No summary available." + + async def _stream_chat_chunks( session: ChatSession, tools: list[ChatCompletionToolParam], @@ -664,6 +759,292 @@ async def _stream_chat_chunks( ) messages = [system_message] + messages + # Apply context window management + token_count = 0 # Initialize for exception handler + try: + from backend.util.prompt import estimate_token_count + + # Convert to dict for token counting + # OpenAI message types are TypedDicts, so they're already dict-like + messages_dict = [] + for msg in messages: + # TypedDict objects are already dicts, just filter None values + if isinstance(msg, dict): + msg_dict = {k: v for k, v in msg.items() if v is not None} + else: + # Fallback for unexpected types + msg_dict = dict(msg) + messages_dict.append(msg_dict) + + # Estimate tokens using appropriate tokenizer + # Normalize model name for token counting (tiktoken only supports OpenAI models) + token_count_model = model + if "/" in model: + # Strip provider prefix (e.g., "anthropic/claude-opus-4.5" -> "claude-opus-4.5") + token_count_model = model.split("/")[-1] + + # For Claude and other non-OpenAI models, approximate with gpt-4o tokenizer + # Most modern LLMs have similar tokenization (~1 token per 4 chars) + if "claude" in token_count_model.lower() or not any( + known in token_count_model.lower() + for known in ["gpt", "o1", "chatgpt", "text-"] + ): + token_count_model = "gpt-4o" + + # Attempt token counting with error handling + try: + token_count = estimate_token_count(messages_dict, model=token_count_model) + except Exception as token_error: + # If token counting fails, use gpt-4o as fallback approximation + logger.warning( + f"Token counting failed for model {token_count_model}: {token_error}. " + "Using gpt-4o approximation." + ) + token_count = estimate_token_count(messages_dict, model="gpt-4o") + + # If over threshold, summarize old messages + if token_count > 120_000: + KEEP_RECENT = 15 + + # Check if we have a system prompt at the start + has_system_prompt = ( + len(messages) > 0 and messages[0].get("role") == "system" + ) + + # Always attempt mitigation when over limit, even with few messages + if messages: + # Split messages based on whether system prompt exists + recent_messages = messages[-KEEP_RECENT:] + + if has_system_prompt: + # Keep system prompt separate, summarize everything between system and recent + system_msg = messages[0] + old_messages_dict = messages_dict[1:-KEEP_RECENT] + else: + # No system prompt, summarize everything except recent + system_msg = None + old_messages_dict = messages_dict[:-KEEP_RECENT] + + # Summarize any non-empty old messages (no minimum threshold) + # If we're over the token limit, we need to compress whatever we can + if old_messages_dict: + # Summarize old messages using the same model as chat + summary_text = await _summarize_messages( + old_messages_dict, + model=model, + api_key=config.api_key, + base_url=config.base_url, + ) + + # Build new message list + # Use assistant role (not system) to prevent privilege escalation + # of user-influenced content to instruction-level authority + from openai.types.chat import ChatCompletionAssistantMessageParam + + summary_msg = ChatCompletionAssistantMessageParam( + role="assistant", + content=( + "[Previous conversation summary — for context only]: " + f"{summary_text}" + ), + ) + + # Rebuild messages based on whether we have a system prompt + if has_system_prompt: + # system_prompt + summary + recent_messages + messages = [system_msg, summary_msg] + recent_messages + else: + # summary + recent_messages (no original system prompt) + messages = [summary_msg] + recent_messages + + logger.info( + f"Context summarized: {token_count} tokens, " + f"summarized {len(old_messages_dict)} old messages, " + f"kept last {KEEP_RECENT} messages" + ) + + # Fallback: If still over limit after summarization, progressively drop recent messages + # This handles edge cases where recent messages are extremely large + new_messages_dict = [] + for msg in messages: + if isinstance(msg, dict): + msg_dict = {k: v for k, v in msg.items() if v is not None} + else: + msg_dict = dict(msg) + new_messages_dict.append(msg_dict) + + new_token_count = estimate_token_count( + new_messages_dict, model=token_count_model + ) + + if new_token_count > 120_000: + # Still over limit - progressively reduce KEEP_RECENT + logger.warning( + f"Still over limit after summarization: {new_token_count} tokens. " + "Reducing number of recent messages kept." + ) + + for keep_count in [12, 10, 8, 5, 3, 2, 1, 0]: + if keep_count == 0: + # Try with just system prompt + summary (no recent messages) + if has_system_prompt: + messages = [system_msg, summary_msg] + else: + messages = [summary_msg] + logger.info( + "Trying with 0 recent messages (system + summary only)" + ) + else: + # Slice from ORIGINAL recent_messages to avoid duplicating summary + reduced_recent = ( + recent_messages[-keep_count:] + if len(recent_messages) >= keep_count + else recent_messages + ) + if has_system_prompt: + messages = [ + system_msg, + summary_msg, + ] + reduced_recent + else: + messages = [summary_msg] + reduced_recent + + new_messages_dict = [] + for msg in messages: + if isinstance(msg, dict): + msg_dict = { + k: v for k, v in msg.items() if v is not None + } + else: + msg_dict = dict(msg) + new_messages_dict.append(msg_dict) + + new_token_count = estimate_token_count( + new_messages_dict, model=token_count_model + ) + + if new_token_count <= 120_000: + logger.info( + f"Reduced to {keep_count} recent messages, " + f"now {new_token_count} tokens" + ) + break + else: + logger.error( + f"Unable to reduce token count below threshold even with 0 messages. " + f"Final count: {new_token_count} tokens" + ) + # ABSOLUTE LAST RESORT: Drop system prompt + # This should only happen if summary itself is massive + if has_system_prompt and len(messages) > 1: + messages = messages[1:] # Drop system prompt + logger.critical( + "CRITICAL: Dropped system prompt as absolute last resort. " + "Behavioral consistency may be affected." + ) + # Yield error to user + yield StreamError( + errorText=( + "Warning: System prompt dropped due to size constraints. " + "Assistant behavior may be affected." + ) + ) + else: + # No old messages to summarize - all messages are "recent" + # Apply progressive truncation to reduce token count + logger.warning( + f"Token count {token_count} exceeds threshold but no old messages to summarize. " + f"Applying progressive truncation to recent messages." + ) + + # Create a base list excluding system prompt to avoid duplication + # This is the pool of messages we'll slice from in the loop + base_msgs = messages[1:] if has_system_prompt else messages + + # Try progressively smaller keep counts + new_token_count = token_count # Initialize with current count + for keep_count in [12, 10, 8, 5, 3, 2, 1, 0]: + if keep_count == 0: + # Try with just system prompt (no recent messages) + if has_system_prompt: + messages = [system_msg] + logger.info( + "Trying with 0 recent messages (system prompt only)" + ) + else: + # No system prompt and no recent messages = empty messages list + # This is invalid, skip this iteration + continue + else: + if len(base_msgs) < keep_count: + continue # Skip if we don't have enough messages + + # Slice from base_msgs to get recent messages (without system prompt) + recent_messages = base_msgs[-keep_count:] + + if has_system_prompt: + messages = [system_msg] + recent_messages + else: + messages = recent_messages + + new_messages_dict = [] + for msg in messages: + if msg is None: + continue # Skip None messages (type safety) + if isinstance(msg, dict): + msg_dict = { + k: v for k, v in msg.items() if v is not None + } + else: + msg_dict = dict(msg) + new_messages_dict.append(msg_dict) + + new_token_count = estimate_token_count( + new_messages_dict, model=token_count_model + ) + + if new_token_count <= 120_000: + logger.info( + f"Reduced to {keep_count} recent messages, " + f"now {new_token_count} tokens" + ) + break + else: + # Even with 0 messages still over limit + logger.error( + f"Unable to reduce token count below threshold even with 0 messages. " + f"Final count: {new_token_count} tokens. Messages may be extremely large." + ) + # ABSOLUTE LAST RESORT: Drop system prompt + if has_system_prompt and len(messages) > 1: + messages = messages[1:] # Drop system prompt + logger.critical( + "CRITICAL: Dropped system prompt as absolute last resort. " + "Behavioral consistency may be affected." + ) + # Yield error to user + yield StreamError( + errorText=( + "Warning: System prompt dropped due to size constraints. " + "Assistant behavior may be affected." + ) + ) + + except Exception as e: + logger.error(f"Context summarization failed: {e}", exc_info=True) + # If we were over the token limit, yield error to user + # Don't silently continue with oversized messages that will fail + if token_count > 120_000: + yield StreamError( + errorText=( + f"Unable to manage context window (token limit exceeded: {token_count} tokens). " + "Context summarization failed. Please start a new conversation." + ) + ) + yield StreamFinish() + return + # Otherwise, continue with original messages (under limit) + # Loop to handle tool calls and continue conversation while True: retry_count = 0 @@ -691,13 +1072,20 @@ async def _stream_chat_chunks( ] # OpenRouter limit # Create the stream with proper types + from typing import cast + + from openai.types.chat import ( + ChatCompletionMessageParam, + ChatCompletionStreamOptionsParam, + ) + stream = await client.chat.completions.create( model=model, - messages=messages, + messages=cast(list[ChatCompletionMessageParam], messages), tools=tools, tool_choice="auto", stream=True, - stream_options={"include_usage": True}, + stream_options=ChatCompletionStreamOptionsParam(include_usage=True), extra_body=extra_body, ) From fac10c422bb49b164ab205142694199990136122 Mon Sep 17 00:00:00 2001 From: Swifty Date: Tue, 27 Jan 2026 15:41:58 +0100 Subject: [PATCH 16/36] fix(backend): add SSE heartbeats to prevent tool execution timeouts (#11855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Long-running chat tools (like `create_agent` and `edit_agent`) were timing out because no SSE data was sent during tool execution. GCP load balancers and proxies have idle connection timeouts (~60 seconds), and when the external Agent Generator service takes longer than this, the connection would drop. This PR adds SSE heartbeat comments during tool execution to keep connections alive. ### Changes šŸ—ļø - **response_model.py**: Added `StreamHeartbeat` response type that emits SSE comments (`: heartbeat\n\n`) - **service.py**: Modified `_yield_tool_call()` to: - Run tool execution in a background asyncio task - Yield heartbeat events every 15 seconds while waiting - Handle task failures with explicit error responses (no silent failures) - Handle cancellation gracefully - **create_agent.py**: Improved error messages with more context and details - **edit_agent.py**: Improved error messages with more context and details ### How It Works ``` Tool Call → Background Task Started │ ā”œā”€ā”€ Every 15 seconds: yield `: heartbeat\n\n` (SSE comment) │ └── Task Complete → yield tool result OR error response ``` SSE comments (`: heartbeat\n\n`) are: - Ignored by SSE clients (don't trigger events) - Keep TCP connections alive through proxies/load balancers - Don't affect the AI SDK data protocol ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] All chat service tests pass (17 tests) - [x] Verified heartbeats are sent during long tool execution - [x] Verified errors are properly reported to frontend --- .../api/features/chat/response_model.py | 18 ++++++ .../backend/api/features/chat/service.py | 58 +++++++++++++++++-- .../api/features/chat/tools/create_agent.py | 14 +++-- .../api/features/chat/tools/edit_agent.py | 5 +- 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/chat/response_model.py b/autogpt_platform/backend/backend/api/features/chat/response_model.py index 49a9b38e8f..53a8cf3a1f 100644 --- a/autogpt_platform/backend/backend/api/features/chat/response_model.py +++ b/autogpt_platform/backend/backend/api/features/chat/response_model.py @@ -31,6 +31,7 @@ class ResponseType(str, Enum): # Other ERROR = "error" USAGE = "usage" + HEARTBEAT = "heartbeat" class StreamBaseResponse(BaseModel): @@ -142,3 +143,20 @@ class StreamError(StreamBaseResponse): details: dict[str, Any] | None = Field( default=None, description="Additional error details" ) + + +class StreamHeartbeat(StreamBaseResponse): + """Heartbeat to keep SSE connection alive during long-running operations. + + Uses SSE comment format (: comment) which is ignored by clients but keeps + the connection alive through proxies and load balancers. + """ + + type: ResponseType = ResponseType.HEARTBEAT + toolCallId: str | None = Field( + default=None, description="Tool call ID if heartbeat is for a specific tool" + ) + + def to_sse(self) -> str: + """Convert to SSE comment format to keep connection alive.""" + return ": heartbeat\n\n" diff --git a/autogpt_platform/backend/backend/api/features/chat/service.py b/autogpt_platform/backend/backend/api/features/chat/service.py index f8336b9107..386b37784d 100644 --- a/autogpt_platform/backend/backend/api/features/chat/service.py +++ b/autogpt_platform/backend/backend/api/features/chat/service.py @@ -38,6 +38,7 @@ from .response_model import ( StreamBaseResponse, StreamError, StreamFinish, + StreamHeartbeat, StreamStart, StreamTextDelta, StreamTextEnd, @@ -48,6 +49,7 @@ from .response_model import ( StreamUsage, ) from .tools import execute_tool, tools +from .tools.models import ErrorResponse from .tracking import track_user_message logger = logging.getLogger(__name__) @@ -1258,6 +1260,9 @@ async def _yield_tool_call( """ Yield a tool call and its execution result. + For long-running tools, yields heartbeat events every 15 seconds to keep + the SSE connection alive through proxies and load balancers. + Raises: orjson.JSONDecodeError: If tool call arguments cannot be parsed as JSON KeyError: If expected tool call fields are missing @@ -1280,12 +1285,53 @@ async def _yield_tool_call( input=arguments, ) - tool_execution_response: StreamToolOutputAvailable = await execute_tool( - tool_name=tool_name, - parameters=arguments, - tool_call_id=tool_call_id, - user_id=session.user_id, - session=session, + # Run tool execution in background task with heartbeats to keep connection alive + tool_task = asyncio.create_task( + execute_tool( + tool_name=tool_name, + parameters=arguments, + tool_call_id=tool_call_id, + user_id=session.user_id, + session=session, + ) ) + # Yield heartbeats every 15 seconds while waiting for tool to complete + heartbeat_interval = 15.0 # seconds + while not tool_task.done(): + try: + # Wait for either the task to complete or the heartbeat interval + await asyncio.wait_for( + asyncio.shield(tool_task), timeout=heartbeat_interval + ) + except asyncio.TimeoutError: + # Task still running, send heartbeat to keep connection alive + logger.debug(f"Sending heartbeat for tool {tool_name} ({tool_call_id})") + yield StreamHeartbeat(toolCallId=tool_call_id) + except CancelledError: + # Task was cancelled, clean up and propagate + tool_task.cancel() + logger.warning(f"Tool execution cancelled: {tool_name} ({tool_call_id})") + raise + + # Get the result - handle any exceptions that occurred during execution + try: + tool_execution_response: StreamToolOutputAvailable = await tool_task + except Exception as e: + # Task raised an exception - ensure we send an error response to the frontend + logger.error( + f"Tool execution failed: {tool_name} ({tool_call_id}): {e}", exc_info=True + ) + error_response = ErrorResponse( + message=f"Tool execution failed: {e!s}", + error=type(e).__name__, + session_id=session.session_id, + ) + tool_execution_response = StreamToolOutputAvailable( + toolCallId=tool_call_id, + toolName=tool_name, + output=error_response.model_dump_json(), + success=False, + ) + yield tool_execution_response diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py index 6469cc4442..87ca5ebca7 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py @@ -113,8 +113,11 @@ class CreateAgentTool(BaseTool): if decomposition_result is None: return ErrorResponse( - message="Failed to analyze the goal. Please try rephrasing.", - error="Decomposition failed", + message="Failed to analyze the goal. The agent generation service may be unavailable or timed out. Please try again.", + error="decomposition_failed", + details={ + "description": description[:100] + }, # Include context for debugging session_id=session_id, ) @@ -179,8 +182,11 @@ class CreateAgentTool(BaseTool): if agent_json is None: return ErrorResponse( - message="Failed to generate the agent. Please try again.", - error="Generation failed", + message="Failed to generate the agent. The agent generation service may be unavailable or timed out. Please try again.", + error="generation_failed", + details={ + "description": description[:100] + }, # Include context for debugging session_id=session_id, ) diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py index df1e4a9c3e..d65b050f06 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py @@ -142,8 +142,9 @@ class EditAgentTool(BaseTool): if result is None: return ErrorResponse( - message="Failed to generate changes. Please try rephrasing.", - error="Update generation failed", + message="Failed to generate changes. The agent generation service may be unavailable or timed out. Please try again.", + error="update_generation_failed", + details={"agent_id": agent_id, "changes": changes[:100]}, session_id=session_id, ) From 3e9d5d0d50a81d87f2f7de3e1e91db91e21c0d3d Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Tue, 27 Jan 2026 08:43:31 -0600 Subject: [PATCH 17/36] fix(backend): handle race condition in review processing gracefully (#11845) ## Summary - Fixes race condition when multiple concurrent requests try to process the same reviews (e.g., double-click, multiple browser tabs) - Previously the second request would fail with "Reviews not found, access denied, or not in WAITING status" - Now handles this gracefully by treating already-processed reviews with the same decision as success ## Changes - Added `get_reviews_by_node_exec_ids()` function that fetches reviews regardless of status - Modified `process_all_reviews_for_execution()` to handle already-processed reviews - Updated route to use idempotent validation ## Test plan - [x] Linter passes (`poetry run ruff check`) - [x] Type checker passes (`poetry run pyright`) - [x] Formatter passes (`poetry run format`) - [ ] Manual testing: double-click approve button should not cause errors Fixes AUTOGPT-SERVER-7HE --- .../executions/review/review_routes_test.py | 44 ++++++------- .../api/features/executions/review/routes.py | 10 +-- .../backend/backend/data/human_review.py | 65 ++++++++++++++----- 3 files changed, 77 insertions(+), 42 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/executions/review/review_routes_test.py b/autogpt_platform/backend/backend/api/features/executions/review/review_routes_test.py index d0c24f2cf8..c8bbfe4bb0 100644 --- a/autogpt_platform/backend/backend/api/features/executions/review/review_routes_test.py +++ b/autogpt_platform/backend/backend/api/features/executions/review/review_routes_test.py @@ -164,9 +164,9 @@ async def test_process_review_action_approve_success( """Test successful review approval""" # Mock the route functions - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} @@ -244,9 +244,9 @@ async def test_process_review_action_reject_success( """Test successful review rejection""" # Mock the route functions - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} @@ -339,9 +339,9 @@ async def test_process_review_action_mixed_success( # Mock the route functions - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) mock_get_reviews_for_user.return_value = { "test_node_123": sample_pending_review, @@ -463,9 +463,9 @@ async def test_process_review_action_review_not_found( test_user_id: str, ) -> None: """Test error when review is not found""" - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) # Return empty dict to simulate review not found mock_get_reviews_for_user.return_value = {} @@ -506,7 +506,7 @@ async def test_process_review_action_review_not_found( response = await client.post("/api/review/action", json=request_data) assert response.status_code == 404 - assert "No pending review found" in response.json()["detail"] + assert "Review(s) not found" in response.json()["detail"] @pytest.mark.asyncio(loop_scope="session") @@ -517,9 +517,9 @@ async def test_process_review_action_partial_failure( test_user_id: str, ) -> None: """Test handling of partial failures in review processing""" - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} @@ -567,9 +567,9 @@ async def test_process_review_action_invalid_node_exec_id( test_user_id: str, ) -> None: """Test failure when trying to process review with invalid node execution ID""" - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) # Return empty dict to simulate review not found mock_get_reviews_for_user.return_value = {} @@ -596,7 +596,7 @@ async def test_process_review_action_invalid_node_exec_id( # Returns 404 when review is not found assert response.status_code == 404 - assert "No pending review found" in response.json()["detail"] + assert "Review(s) not found" in response.json()["detail"] @pytest.mark.asyncio(loop_scope="session") @@ -607,9 +607,9 @@ async def test_process_review_action_auto_approve_creates_auto_approval_records( test_user_id: str, ) -> None: """Test that auto_approve_future_actions flag creates auto-approval records""" - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} @@ -737,9 +737,9 @@ async def test_process_review_action_without_auto_approve_still_loads_settings( test_user_id: str, ) -> None: """Test that execution context is created with settings even without auto-approve""" - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) mock_get_reviews_for_user.return_value = {"test_node_123": sample_pending_review} @@ -885,9 +885,9 @@ async def test_process_review_action_auto_approve_only_applies_to_approved_revie reviewed_at=FIXED_NOW, ) - # Mock get_pending_reviews_by_node_exec_ids (called to find the graph_exec_id) + # Mock get_reviews_by_node_exec_ids (called to find the graph_exec_id) mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) # Need to return both reviews in WAITING state (before processing) approved_review_waiting = PendingHumanReviewModel( @@ -1031,9 +1031,9 @@ async def test_process_review_action_per_review_auto_approve_granularity( test_user_id: str, ) -> None: """Test that auto-approval can be set per-review (granular control)""" - # Mock get_pending_reviews_by_node_exec_ids - return different reviews based on node_exec_id + # Mock get_reviews_by_node_exec_ids - return different reviews based on node_exec_id mock_get_reviews_for_user = mocker.patch( - "backend.api.features.executions.review.routes.get_pending_reviews_by_node_exec_ids" + "backend.api.features.executions.review.routes.get_reviews_by_node_exec_ids" ) # Create a mapping of node_exec_id to review diff --git a/autogpt_platform/backend/backend/api/features/executions/review/routes.py b/autogpt_platform/backend/backend/api/features/executions/review/routes.py index a10071e9cb..539c7fd87b 100644 --- a/autogpt_platform/backend/backend/api/features/executions/review/routes.py +++ b/autogpt_platform/backend/backend/api/features/executions/review/routes.py @@ -14,9 +14,9 @@ from backend.data.execution import ( from backend.data.graph import get_graph_settings from backend.data.human_review import ( create_auto_approval_record, - get_pending_reviews_by_node_exec_ids, get_pending_reviews_for_execution, get_pending_reviews_for_user, + get_reviews_by_node_exec_ids, has_pending_reviews_for_graph_exec, process_all_reviews_for_execution, ) @@ -137,17 +137,17 @@ async def process_review_action( detail="At least one review must be provided", ) - # Batch fetch all requested reviews - reviews_map = await get_pending_reviews_by_node_exec_ids( + # Batch fetch all requested reviews (regardless of status for idempotent handling) + reviews_map = await get_reviews_by_node_exec_ids( list(all_request_node_ids), user_id ) - # Validate all reviews were found + # Validate all reviews were found (must exist, any status is OK for now) missing_ids = all_request_node_ids - set(reviews_map.keys()) if missing_ids: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"No pending review found for node execution(s): {', '.join(missing_ids)}", + detail=f"Review(s) not found: {', '.join(missing_ids)}", ) # Validate all reviews belong to the same execution diff --git a/autogpt_platform/backend/backend/data/human_review.py b/autogpt_platform/backend/backend/data/human_review.py index c70eaa7b64..f198043a38 100644 --- a/autogpt_platform/backend/backend/data/human_review.py +++ b/autogpt_platform/backend/backend/data/human_review.py @@ -263,11 +263,14 @@ async def get_pending_review_by_node_exec_id( return PendingHumanReviewModel.from_db(review, node_id=node_id) -async def get_pending_reviews_by_node_exec_ids( +async def get_reviews_by_node_exec_ids( node_exec_ids: list[str], user_id: str ) -> dict[str, "PendingHumanReviewModel"]: """ - Get multiple pending reviews by their node execution IDs in a single batch query. + Get multiple reviews by their node execution IDs regardless of status. + + Unlike get_pending_reviews_by_node_exec_ids, this returns reviews in any status + (WAITING, APPROVED, REJECTED). Used for validation in idempotent operations. Args: node_exec_ids: List of node execution IDs to look up @@ -283,7 +286,6 @@ async def get_pending_reviews_by_node_exec_ids( where={ "nodeExecId": {"in": node_exec_ids}, "userId": user_id, - "status": ReviewStatus.WAITING, } ) @@ -407,38 +409,68 @@ async def process_all_reviews_for_execution( ) -> dict[str, PendingHumanReviewModel]: """Process all pending reviews for an execution with approve/reject decisions. + Handles race conditions gracefully: if a review was already processed with the + same decision by a concurrent request, it's treated as success rather than error. + Args: user_id: User ID for ownership validation review_decisions: Map of node_exec_id -> (status, reviewed_data, message) Returns: - Dict of node_exec_id -> updated review model + Dict of node_exec_id -> updated review model (includes already-processed reviews) """ if not review_decisions: return {} node_exec_ids = list(review_decisions.keys()) - # Get all reviews for validation - reviews = await PendingHumanReview.prisma().find_many( + # Get all reviews (both WAITING and already processed) for the user + all_reviews = await PendingHumanReview.prisma().find_many( where={ "nodeExecId": {"in": node_exec_ids}, "userId": user_id, - "status": ReviewStatus.WAITING, }, ) - # Validate all reviews can be processed - if len(reviews) != len(node_exec_ids): - missing_ids = set(node_exec_ids) - {review.nodeExecId for review in reviews} + # Separate into pending and already-processed reviews + reviews_to_process = [] + already_processed = [] + for review in all_reviews: + if review.status == ReviewStatus.WAITING: + reviews_to_process.append(review) + else: + already_processed.append(review) + + # Check for truly missing reviews (not found at all) + found_ids = {review.nodeExecId for review in all_reviews} + missing_ids = set(node_exec_ids) - found_ids + if missing_ids: raise ValueError( - f"Reviews not found, access denied, or not in WAITING status: {', '.join(missing_ids)}" + f"Reviews not found or access denied: {', '.join(missing_ids)}" ) - # Create parallel update tasks + # Validate already-processed reviews have compatible status (same decision) + # This handles race conditions where another request processed the same reviews + for review in already_processed: + requested_status = review_decisions[review.nodeExecId][0] + if review.status != requested_status: + raise ValueError( + f"Review {review.nodeExecId} was already processed with status " + f"{review.status}, cannot change to {requested_status}" + ) + + # Log if we're handling a race condition (some reviews already processed) + if already_processed: + already_processed_ids = [r.nodeExecId for r in already_processed] + logger.info( + f"Race condition handled: {len(already_processed)} review(s) already " + f"processed by concurrent request: {already_processed_ids}" + ) + + # Create parallel update tasks for reviews that still need processing update_tasks = [] - for review in reviews: + for review in reviews_to_process: new_status, reviewed_data, message = review_decisions[review.nodeExecId] has_data_changes = reviewed_data is not None and reviewed_data != review.payload @@ -463,7 +495,7 @@ async def process_all_reviews_for_execution( update_tasks.append(task) # Execute all updates in parallel and get updated reviews - updated_reviews = await asyncio.gather(*update_tasks) + updated_reviews = await asyncio.gather(*update_tasks) if update_tasks else [] # Note: Execution resumption is now handled at the API layer after ALL reviews # for an execution are processed (both approved and rejected) @@ -472,8 +504,11 @@ async def process_all_reviews_for_execution( # Local import to avoid event loop conflicts in tests from backend.data.execution import get_node_execution + # Combine updated reviews with already-processed ones (for idempotent response) + all_result_reviews = list(updated_reviews) + already_processed + result = {} - for review in updated_reviews: + for review in all_result_reviews: node_exec = await get_node_execution(review.nodeExecId) node_id = node_exec.node_id if node_exec else review.nodeExecId result[review.nodeExecId] = PendingHumanReviewModel.from_db( From 962824c8afd9a566531ee123262dc6fe57dcb679 Mon Sep 17 00:00:00 2001 From: Ubbe Date: Tue, 27 Jan 2026 22:09:25 +0700 Subject: [PATCH 18/36] refactor(frontend): copilot session management stream updates (#11853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes šŸ—ļø - **Fix infinite loop in copilot page** - use Zustand selectors instead of full store object to get stable function references - **Centralize chat streaming logic** - move all streaming files from `providers/chat-stream/` to `components/contextual/Chat/` for better colocation and reusability - **Rename `copilot-store` → `copilot-page-store`**: Clarify scope - **Fix message duplication** - Only replay chunks from active streams (not completed ones) since backend already provides persisted messages in `initialMessages` - **Auto-focus chat input** - Focus textarea when streaming ends and input is re-enabled - **Graceful error display** - Render tool response errors in muted style (small text + warning icon) instead of raw "Error: ..." text ## Checklist šŸ“‹ ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Navigate to copilot page - no infinite loop errors - [x] Start a new chat, send message, verify streaming works - [x] Navigate away and back to a completed session - no duplicate messages - [x] After stream completes, verify chat input receives focus - [x] Trigger a tool error - verify it displays with muted styling --- .../app/(platform)/copilot/NewChatContext.tsx | 41 -- .../components/CopilotShell/CopilotShell.tsx | 22 +- .../SessionsList/useSessionsPagination.ts | 73 ++- .../components/CopilotShell/helpers.ts | 70 +- .../CopilotShell/useCopilotShell.ts | 45 +- .../(platform)/copilot/copilot-page-store.ts | 54 ++ .../src/app/(platform)/copilot/layout.tsx | 7 +- .../src/app/(platform)/copilot/page.tsx | 30 +- .../app/(platform)/copilot/useCopilotPage.ts | 74 +-- .../frontend/src/app/(platform)/layout.tsx | 2 + .../components/atoms/Skeleton/Skeleton.tsx | 14 + .../atoms/Skeleton/skeleton.stories.tsx | 2 +- .../components/contextual/Chat/chat-store.ts | 234 +++++++ .../components/contextual/Chat/chat-types.ts | 94 +++ .../ChatContainer/ChatContainer.tsx | 24 +- .../createStreamEventDispatcher.ts | 2 +- .../Chat/components/ChatContainer/helpers.ts | 111 ++++ .../ChatContainer/useChatContainer.ts | 257 +++----- .../Chat/components/ChatInput/ChatInput.tsx | 11 +- .../Chat/components/ChatInput/useChatInput.ts | 55 +- .../LastToolResponse/LastToolResponse.tsx | 15 +- .../ToolResponseMessage.tsx | 27 +- .../components/ToolResponseMessage/helpers.ts | 43 +- .../contextual/Chat/stream-executor.ts | 142 ++++ .../contextual/Chat/stream-utils.ts | 84 +++ .../src/components/contextual/Chat/useChat.ts | 41 +- .../contextual/Chat/useChatDrawer.ts | 17 - .../contextual/Chat/useChatSession.ts | 12 + .../contextual/Chat/useChatStream.ts | 615 +++--------------- .../providers/posthog/posthog-provider.tsx | 7 +- .../network-status/NetworkStatusMonitor.tsx | 8 + .../network-status/useNetworkStatus.ts | 28 + 32 files changed, 1274 insertions(+), 987 deletions(-) delete mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/NewChatContext.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts create mode 100644 autogpt_platform/frontend/src/components/atoms/Skeleton/Skeleton.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/chat-store.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/chat-types.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/stream-executor.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/stream-utils.ts delete mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/useChatDrawer.ts create mode 100644 autogpt_platform/frontend/src/services/network-status/NetworkStatusMonitor.tsx create mode 100644 autogpt_platform/frontend/src/services/network-status/useNetworkStatus.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/NewChatContext.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/NewChatContext.tsx deleted file mode 100644 index 0826637043..0000000000 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/NewChatContext.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { createContext, useContext, useRef, type ReactNode } from "react"; - -interface NewChatContextValue { - onNewChatClick: () => void; - setOnNewChatClick: (handler?: () => void) => void; - performNewChat?: () => void; - setPerformNewChat: (handler?: () => void) => void; -} - -const NewChatContext = createContext(null); - -export function NewChatProvider({ children }: { children: ReactNode }) { - const onNewChatRef = useRef<(() => void) | undefined>(); - const performNewChatRef = useRef<(() => void) | undefined>(); - const contextValueRef = useRef({ - onNewChatClick() { - onNewChatRef.current?.(); - }, - setOnNewChatClick(handler?: () => void) { - onNewChatRef.current = handler; - }, - performNewChat() { - performNewChatRef.current?.(); - }, - setPerformNewChat(handler?: () => void) { - performNewChatRef.current = handler; - }, - }); - - return ( - - {children} - - ); -} - -export function useNewChat() { - return useContext(NewChatContext); -} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx index 44e32024a8..fb22640302 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx @@ -4,7 +4,7 @@ import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/C import { NAVBAR_HEIGHT_PX } from "@/lib/constants"; import type { ReactNode } from "react"; import { useEffect } from "react"; -import { useNewChat } from "../../NewChatContext"; +import { useCopilotStore } from "../../copilot-page-store"; import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar"; import { LoadingState } from "./components/LoadingState/LoadingState"; import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer"; @@ -35,21 +35,23 @@ export function CopilotShell({ children }: Props) { isReadyToShowContent, } = useCopilotShell(); - const newChatContext = useNewChat(); - const handleNewChatClickWrapper = - newChatContext?.onNewChatClick || handleNewChat; + const setNewChatHandler = useCopilotStore((s) => s.setNewChatHandler); + const requestNewChat = useCopilotStore((s) => s.requestNewChat); useEffect( function registerNewChatHandler() { - if (!newChatContext) return; - newChatContext.setPerformNewChat(handleNewChat); + setNewChatHandler(handleNewChat); return function cleanup() { - newChatContext.setPerformNewChat(undefined); + setNewChatHandler(null); }; }, - [newChatContext, handleNewChat], + [handleNewChat], ); + function handleNewChatClick() { + requestNewChat(); + } + if (!isLoggedIn) { return (
@@ -72,7 +74,7 @@ export function CopilotShell({ children }: Props) { isFetchingNextPage={isFetchingNextPage} onSelectSession={handleSelectSession} onFetchNextPage={fetchNextPage} - onNewChat={handleNewChatClickWrapper} + onNewChat={handleNewChatClick} hasActiveSession={Boolean(hasActiveSession)} /> )} @@ -94,7 +96,7 @@ export function CopilotShell({ children }: Props) { isFetchingNextPage={isFetchingNextPage} onSelectSession={handleSelectSession} onFetchNextPage={fetchNextPage} - onNewChat={handleNewChatClickWrapper} + onNewChat={handleNewChatClick} onClose={handleCloseDrawer} onOpenChange={handleDrawerOpenChange} hasActiveSession={Boolean(hasActiveSession)} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts index 8833a419c1..1f241f992a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts @@ -1,7 +1,12 @@ -import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat"; +import { + getGetV2ListSessionsQueryKey, + useGetV2ListSessions, +} from "@/app/api/__generated__/endpoints/chat/chat"; import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; import { okData } from "@/app/api/helpers"; -import { useEffect, useMemo, useState } from "react"; +import { useChatStore } from "@/components/contextual/Chat/chat-store"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; const PAGE_SIZE = 50; @@ -15,6 +20,8 @@ export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) { SessionSummaryResponse[] >([]); const [totalCount, setTotalCount] = useState(null); + const queryClient = useQueryClient(); + const onStreamComplete = useChatStore((state) => state.onStreamComplete); const { data, isLoading, isFetching, isError } = useGetV2ListSessions( { limit: PAGE_SIZE, offset }, @@ -25,35 +32,47 @@ export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) { }, ); - useEffect(() => { - const responseData = okData(data); - if (responseData) { - const newSessions = responseData.sessions; - const total = responseData.total; - setTotalCount(total); - - if (offset === 0) { - setAccumulatedSessions(newSessions); - } else { - setAccumulatedSessions((prev) => [...prev, ...newSessions]); - } - } else if (!enabled) { + useEffect(function refreshOnStreamComplete() { + const unsubscribe = onStreamComplete(function handleStreamComplete() { + setOffset(0); setAccumulatedSessions([]); setTotalCount(null); - } - }, [data, offset, enabled]); + queryClient.invalidateQueries({ + queryKey: getGetV2ListSessionsQueryKey(), + }); + }); + return unsubscribe; + }, []); - const hasNextPage = useMemo(() => { - if (totalCount === null) return false; - return accumulatedSessions.length < totalCount; - }, [accumulatedSessions.length, totalCount]); + useEffect( + function updateSessionsFromResponse() { + const responseData = okData(data); + if (responseData) { + const newSessions = responseData.sessions; + const total = responseData.total; + setTotalCount(total); - const areAllSessionsLoaded = useMemo(() => { - if (totalCount === null) return false; - return ( - accumulatedSessions.length >= totalCount && !isFetching && !isLoading - ); - }, [accumulatedSessions.length, totalCount, isFetching, isLoading]); + if (offset === 0) { + setAccumulatedSessions(newSessions); + } else { + setAccumulatedSessions((prev) => [...prev, ...newSessions]); + } + } else if (!enabled) { + setAccumulatedSessions([]); + setTotalCount(null); + } + }, + [data, offset, enabled], + ); + + const hasNextPage = + totalCount !== null && accumulatedSessions.length < totalCount; + + const areAllSessionsLoaded = + totalCount !== null && + accumulatedSessions.length >= totalCount && + !isFetching && + !isLoading; useEffect(() => { if ( diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts index bf4eb70ccb..3e932848a0 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts @@ -2,9 +2,7 @@ import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessi import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; import { format, formatDistanceToNow, isToday } from "date-fns"; -export function convertSessionDetailToSummary( - session: SessionDetailResponse, -): SessionSummaryResponse { +export function convertSessionDetailToSummary(session: SessionDetailResponse) { return { id: session.id, created_at: session.created_at, @@ -13,17 +11,25 @@ export function convertSessionDetailToSummary( }; } -export function filterVisibleSessions( - sessions: SessionSummaryResponse[], -): SessionSummaryResponse[] { - return sessions.filter( - (session) => session.updated_at !== session.created_at, - ); +export function filterVisibleSessions(sessions: SessionSummaryResponse[]) { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + return sessions.filter((session) => { + const hasBeenUpdated = session.updated_at !== session.created_at; + + if (hasBeenUpdated) return true; + + const isRecentlyCreated = + new Date(session.created_at).getTime() > fiveMinutesAgo; + + return isRecentlyCreated; + }); } -export function getSessionTitle(session: SessionSummaryResponse): string { +export function getSessionTitle(session: SessionSummaryResponse) { if (session.title) return session.title; + const isNewSession = session.updated_at === session.created_at; + if (isNewSession) { const createdDate = new Date(session.created_at); if (isToday(createdDate)) { @@ -31,12 +37,11 @@ export function getSessionTitle(session: SessionSummaryResponse): string { } return format(createdDate, "MMM d, yyyy"); } + return "Untitled Chat"; } -export function getSessionUpdatedLabel( - session: SessionSummaryResponse, -): string { +export function getSessionUpdatedLabel(session: SessionSummaryResponse) { if (!session.updated_at) return ""; return formatDistanceToNow(new Date(session.updated_at), { addSuffix: true }); } @@ -45,8 +50,10 @@ export function mergeCurrentSessionIntoList( accumulatedSessions: SessionSummaryResponse[], currentSessionId: string | null, currentSessionData: SessionDetailResponse | null | undefined, -): SessionSummaryResponse[] { + recentlyCreatedSessions?: Map, +) { const filteredSessions: SessionSummaryResponse[] = []; + const addedIds = new Set(); if (accumulatedSessions.length > 0) { const visibleSessions = filterVisibleSessions(accumulatedSessions); @@ -61,29 +68,40 @@ export function mergeCurrentSessionIntoList( ); if (!isInVisible) { filteredSessions.push(currentInAll); + addedIds.add(currentInAll.id); } } } - filteredSessions.push(...visibleSessions); + for (const session of visibleSessions) { + if (!addedIds.has(session.id)) { + filteredSessions.push(session); + addedIds.add(session.id); + } + } } if (currentSessionId && currentSessionData) { - const isCurrentInList = filteredSessions.some( - (s) => s.id === currentSessionId, - ); - if (!isCurrentInList) { + if (!addedIds.has(currentSessionId)) { const summarySession = convertSessionDetailToSummary(currentSessionData); filteredSessions.unshift(summarySession); + addedIds.add(currentSessionId); + } + } + + if (recentlyCreatedSessions) { + for (const [sessionId, sessionData] of recentlyCreatedSessions) { + if (!addedIds.has(sessionId)) { + filteredSessions.unshift(sessionData); + addedIds.add(sessionId); + } } } return filteredSessions; } -export function getCurrentSessionId( - searchParams: URLSearchParams, -): string | null { +export function getCurrentSessionId(searchParams: URLSearchParams) { return searchParams.get("sessionId"); } @@ -95,11 +113,7 @@ export function shouldAutoSelectSession( accumulatedSessions: SessionSummaryResponse[], isLoading: boolean, totalCount: number | null, -): { - shouldSelect: boolean; - sessionIdToSelect: string | null; - shouldCreate: boolean; -} { +) { if (!areAllSessionsLoaded || hasAutoSelectedSession) { return { shouldSelect: false, @@ -146,7 +160,7 @@ export function checkReadyToShowContent( isCurrentSessionLoading: boolean, currentSessionData: SessionDetailResponse | null | undefined, hasAutoSelectedSession: boolean, -): boolean { +) { if (!areAllSessionsLoaded) return false; if (paramSessionId) { diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts index cadd98da3e..a3aa0b55b2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts @@ -4,23 +4,25 @@ import { getGetV2ListSessionsQueryKey, useGetV2GetSession, } from "@/app/api/__generated__/endpoints/chat/chat"; +import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; import { okData } from "@/app/api/helpers"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useQueryClient } from "@tanstack/react-query"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { parseAsString, useQueryState } from "nuqs"; +import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer"; import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination"; import { checkReadyToShowContent, + convertSessionDetailToSummary, filterVisibleSessions, getCurrentSessionId, mergeCurrentSessionIntoList, } from "./helpers"; export function useCopilotShell() { - const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); @@ -29,6 +31,8 @@ export function useCopilotShell() { const isMobile = breakpoint === "base" || breakpoint === "sm" || breakpoint === "md"; + const [, setUrlSessionId] = useQueryState("sessionId", parseAsString); + const isOnHomepage = pathname === "/copilot"; const paramSessionId = searchParams.get("sessionId"); @@ -65,6 +69,9 @@ export function useCopilotShell() { const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false); const hasAutoSelectedRef = useRef(false); + const recentlyCreatedSessionsRef = useRef< + Map + >(new Map()); // Mark as auto-selected when sessionId is in URL useEffect(() => { @@ -91,6 +98,30 @@ export function useCopilotShell() { } }, [isOnHomepage, paramSessionId, queryClient]); + // Track newly created sessions to ensure they stay visible even when switching away + useEffect(() => { + if (currentSessionId && currentSessionData) { + const isNewSession = + currentSessionData.updated_at === currentSessionData.created_at; + const isNotInAccumulated = !accumulatedSessions.some( + (s) => s.id === currentSessionId, + ); + if (isNewSession || isNotInAccumulated) { + const summary = convertSessionDetailToSummary(currentSessionData); + recentlyCreatedSessionsRef.current.set(currentSessionId, summary); + } + } + }, [currentSessionId, currentSessionData, accumulatedSessions]); + + // Clean up recently created sessions that are now in the accumulated list + useEffect(() => { + for (const sessionId of recentlyCreatedSessionsRef.current.keys()) { + if (accumulatedSessions.some((s) => s.id === sessionId)) { + recentlyCreatedSessionsRef.current.delete(sessionId); + } + } + }, [accumulatedSessions]); + // Reset pagination when query becomes disabled const prevPaginationEnabledRef = useRef(paginationEnabled); useEffect(() => { @@ -105,6 +136,7 @@ export function useCopilotShell() { accumulatedSessions, currentSessionId, currentSessionData, + recentlyCreatedSessionsRef.current, ); const visibleSessions = filterVisibleSessions(sessions); @@ -124,22 +156,17 @@ export function useCopilotShell() { ); function handleSelectSession(sessionId: string) { - // Navigate using replaceState to avoid full page reload - window.history.replaceState(null, "", `/copilot?sessionId=${sessionId}`); - // Force a re-render by updating the URL through router - router.replace(`/copilot?sessionId=${sessionId}`); + setUrlSessionId(sessionId, { shallow: false }); if (isMobile) handleCloseDrawer(); } function handleNewChat() { resetAutoSelect(); resetPagination(); - // Invalidate and refetch sessions list to ensure newly created sessions appear queryClient.invalidateQueries({ queryKey: getGetV2ListSessionsQueryKey(), }); - window.history.replaceState(null, "", "/copilot"); - router.replace("/copilot"); + setUrlSessionId(null, { shallow: false }); if (isMobile) handleCloseDrawer(); } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts new file mode 100644 index 0000000000..22bf5000a1 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts @@ -0,0 +1,54 @@ +"use client"; + +import { create } from "zustand"; + +interface CopilotStoreState { + isStreaming: boolean; + isNewChatModalOpen: boolean; + newChatHandler: (() => void) | null; +} + +interface CopilotStoreActions { + setIsStreaming: (isStreaming: boolean) => void; + setNewChatHandler: (handler: (() => void) | null) => void; + requestNewChat: () => void; + confirmNewChat: () => void; + cancelNewChat: () => void; +} + +type CopilotStore = CopilotStoreState & CopilotStoreActions; + +export const useCopilotStore = create((set, get) => ({ + isStreaming: false, + isNewChatModalOpen: false, + newChatHandler: null, + + setIsStreaming(isStreaming) { + set({ isStreaming }); + }, + + setNewChatHandler(handler) { + set({ newChatHandler: handler }); + }, + + requestNewChat() { + const { isStreaming, newChatHandler } = get(); + if (isStreaming) { + set({ isNewChatModalOpen: true }); + } else if (newChatHandler) { + newChatHandler(); + } + }, + + confirmNewChat() { + const { newChatHandler } = get(); + set({ isNewChatModalOpen: false }); + if (newChatHandler) { + newChatHandler(); + } + }, + + cancelNewChat() { + set({ isNewChatModalOpen: false }); + }, +})); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx index 0f40de8f25..89cf72e2ba 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx @@ -1,11 +1,6 @@ import type { ReactNode } from "react"; -import { NewChatProvider } from "./NewChatContext"; import { CopilotShell } from "./components/CopilotShell/CopilotShell"; export default function CopilotLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ); + return {children}; } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx index 3bbafd087b..83b21bf82e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx @@ -1,16 +1,19 @@ "use client"; -import { Skeleton } from "@/components/__legacy__/ui/skeleton"; import { Button } from "@/components/atoms/Button/Button"; + +import { Skeleton } from "@/components/atoms/Skeleton/Skeleton"; import { Text } from "@/components/atoms/Text/Text"; import { Chat } from "@/components/contextual/Chat/Chat"; import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput"; import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader"; import { Dialog } from "@/components/molecules/Dialog/Dialog"; +import { useCopilotStore } from "./copilot-page-store"; import { useCopilotPage } from "./useCopilotPage"; export default function CopilotPage() { const { state, handlers } = useCopilotPage(); + const confirmNewChat = useCopilotStore((s) => s.confirmNewChat); const { greetingName, quickActions, @@ -25,15 +28,11 @@ export default function CopilotPage() { handleSessionNotFound, handleStreamingChange, handleCancelNewChat, - proceedWithNewChat, handleNewChatModalOpen, } = handlers; - if (!isReady) { - return null; - } + if (!isReady) return null; - // Show Chat when we have an active session if (pageState.type === "chat") { return (
@@ -71,7 +70,7 @@ export default function CopilotPage() { @@ -83,7 +82,7 @@ export default function CopilotPage() { ); } - if (pageState.type === "newChat") { + if (pageState.type === "newChat" || pageState.type === "creating") { return (
@@ -96,21 +95,6 @@ export default function CopilotPage() { ); } - // Show loading state while creating session and sending first message - if (pageState.type === "creating") { - return ( -
-
- - - Loading your chats... - -
-
- ); - } - - // Show Welcome screen return (
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index cb13137432..1d9c843d7d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -1,4 +1,7 @@ -import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat"; +import { + getGetV2ListSessionsQueryKey, + postV2CreateSession, +} from "@/app/api/__generated__/endpoints/chat/chat"; import { useToast } from "@/components/molecules/Toast/use-toast"; import { getHomepageRoute } from "@/lib/constants"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; @@ -8,25 +11,22 @@ import { useGetFlag, } from "@/services/feature-flags/use-get-flag"; import * as Sentry from "@sentry/nextjs"; +import { useQueryClient } from "@tanstack/react-query"; import { useFlags } from "launchdarkly-react-client-sdk"; import { useRouter } from "next/navigation"; import { useEffect, useReducer } from "react"; -import { useNewChat } from "./NewChatContext"; +import { useCopilotStore } from "./copilot-page-store"; import { getGreetingName, getQuickActions, type PageState } from "./helpers"; import { useCopilotURLState } from "./useCopilotURLState"; type CopilotState = { pageState: PageState; - isStreaming: boolean; - isNewChatModalOpen: boolean; initialPrompts: Record; previousSessionId: string | null; }; type CopilotAction = | { type: "setPageState"; pageState: PageState } - | { type: "setStreaming"; isStreaming: boolean } - | { type: "setNewChatModalOpen"; isOpen: boolean } | { type: "setInitialPrompt"; sessionId: string; prompt: string } | { type: "setPreviousSessionId"; sessionId: string | null }; @@ -52,14 +52,6 @@ function copilotReducer( if (isSamePageState(action.pageState, state.pageState)) return state; return { ...state, pageState: action.pageState }; } - if (action.type === "setStreaming") { - if (action.isStreaming === state.isStreaming) return state; - return { ...state, isStreaming: action.isStreaming }; - } - if (action.type === "setNewChatModalOpen") { - if (action.isOpen === state.isNewChatModalOpen) return state; - return { ...state, isNewChatModalOpen: action.isOpen }; - } if (action.type === "setInitialPrompt") { if (state.initialPrompts[action.sessionId] === action.prompt) return state; return { @@ -79,9 +71,14 @@ function copilotReducer( export function useCopilotPage() { const router = useRouter(); + const queryClient = useQueryClient(); const { user, isLoggedIn, isUserLoading } = useSupabase(); const { toast } = useToast(); + const isNewChatModalOpen = useCopilotStore((s) => s.isNewChatModalOpen); + const setIsStreaming = useCopilotStore((s) => s.setIsStreaming); + const cancelNewChat = useCopilotStore((s) => s.cancelNewChat); + const isChatEnabled = useGetFlag(Flag.CHAT); const flags = useFlags(); const homepageRoute = getHomepageRoute(isChatEnabled); @@ -93,13 +90,10 @@ export function useCopilotPage() { const [state, dispatch] = useReducer(copilotReducer, { pageState: { type: "welcome" }, - isStreaming: false, - isNewChatModalOpen: false, initialPrompts: {}, previousSessionId: null, }); - const newChatContext = useNewChat(); const greetingName = getGreetingName(user); const quickActions = getQuickActions(); @@ -124,17 +118,6 @@ export function useCopilotPage() { setPreviousSessionId, }); - useEffect( - function registerNewChatHandler() { - if (!newChatContext) return; - newChatContext.setOnNewChatClick(handleNewChatClick); - return function cleanup() { - newChatContext.setOnNewChatClick(undefined); - }; - }, - [newChatContext, handleNewChatClick], - ); - useEffect( function transitionNewChatToWelcome() { if (state.pageState.type === "newChat") { @@ -189,6 +172,10 @@ export function useCopilotPage() { prompt: trimmedPrompt, }); + await queryClient.invalidateQueries({ + queryKey: getGetV2ListSessionsQueryKey(), + }); + await setUrlSessionId(sessionId, { shallow: false }); dispatch({ type: "setPageState", @@ -211,37 +198,15 @@ export function useCopilotPage() { } function handleStreamingChange(isStreamingValue: boolean) { - dispatch({ type: "setStreaming", isStreaming: isStreamingValue }); - } - - async function proceedWithNewChat() { - dispatch({ type: "setNewChatModalOpen", isOpen: false }); - if (newChatContext?.performNewChat) { - newChatContext.performNewChat(); - return; - } - try { - await setUrlSessionId(null, { shallow: false }); - } catch (error) { - console.error("[CopilotPage] Failed to clear session:", error); - } - router.replace("/copilot"); + setIsStreaming(isStreamingValue); } function handleCancelNewChat() { - dispatch({ type: "setNewChatModalOpen", isOpen: false }); + cancelNewChat(); } function handleNewChatModalOpen(isOpen: boolean) { - dispatch({ type: "setNewChatModalOpen", isOpen }); - } - - function handleNewChatClick() { - if (state.isStreaming) { - dispatch({ type: "setNewChatModalOpen", isOpen: true }); - } else { - proceedWithNewChat(); - } + if (!isOpen) cancelNewChat(); } return { @@ -250,7 +215,7 @@ export function useCopilotPage() { quickActions, isLoading: isUserLoading, pageState: state.pageState, - isNewChatModalOpen: state.isNewChatModalOpen, + isNewChatModalOpen, isReady: isFlagReady && isChatEnabled !== false && isLoggedIn, }, handlers: { @@ -259,7 +224,6 @@ export function useCopilotPage() { handleSessionNotFound, handleStreamingChange, handleCancelNewChat, - proceedWithNewChat, handleNewChatModalOpen, }, }; diff --git a/autogpt_platform/frontend/src/app/(platform)/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/layout.tsx index f5e3f3b99b..048110f8b2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/layout.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/layout.tsx @@ -1,10 +1,12 @@ import { Navbar } from "@/components/layout/Navbar/Navbar"; +import { NetworkStatusMonitor } from "@/services/network-status/NetworkStatusMonitor"; import { ReactNode } from "react"; import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner"; export default function PlatformLayout({ children }: { children: ReactNode }) { return (
+
{children}
diff --git a/autogpt_platform/frontend/src/components/atoms/Skeleton/Skeleton.tsx b/autogpt_platform/frontend/src/components/atoms/Skeleton/Skeleton.tsx new file mode 100644 index 0000000000..4789e281ce --- /dev/null +++ b/autogpt_platform/frontend/src/components/atoms/Skeleton/Skeleton.tsx @@ -0,0 +1,14 @@ +import { cn } from "@/lib/utils"; + +interface Props extends React.HTMLAttributes { + className?: string; +} + +export function Skeleton({ className, ...props }: Props) { + return ( +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/atoms/Skeleton/skeleton.stories.tsx b/autogpt_platform/frontend/src/components/atoms/Skeleton/skeleton.stories.tsx index 04d87a6e0e..69bb7c3440 100644 --- a/autogpt_platform/frontend/src/components/atoms/Skeleton/skeleton.stories.tsx +++ b/autogpt_platform/frontend/src/components/atoms/Skeleton/skeleton.stories.tsx @@ -1,4 +1,4 @@ -import { Skeleton } from "@/components/__legacy__/ui/skeleton"; +import { Skeleton } from "./Skeleton"; import type { Meta, StoryObj } from "@storybook/nextjs"; const meta: Meta = { diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/chat-store.ts b/autogpt_platform/frontend/src/components/contextual/Chat/chat-store.ts new file mode 100644 index 0000000000..28028369a9 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/chat-store.ts @@ -0,0 +1,234 @@ +"use client"; + +import { create } from "zustand"; +import type { + ActiveStream, + StreamChunk, + StreamCompleteCallback, + StreamResult, + StreamStatus, +} from "./chat-types"; +import { executeStream } from "./stream-executor"; + +const COMPLETED_STREAM_TTL = 5 * 60 * 1000; // 5 minutes + +interface ChatStoreState { + activeStreams: Map; + completedStreams: Map; + activeSessions: Set; + streamCompleteCallbacks: Set; +} + +interface ChatStoreActions { + startStream: ( + sessionId: string, + message: string, + isUserMessage: boolean, + context?: { url: string; content: string }, + onChunk?: (chunk: StreamChunk) => void, + ) => Promise; + stopStream: (sessionId: string) => void; + subscribeToStream: ( + sessionId: string, + onChunk: (chunk: StreamChunk) => void, + skipReplay?: boolean, + ) => () => void; + getStreamStatus: (sessionId: string) => StreamStatus; + getCompletedStream: (sessionId: string) => StreamResult | undefined; + clearCompletedStream: (sessionId: string) => void; + isStreaming: (sessionId: string) => boolean; + registerActiveSession: (sessionId: string) => void; + unregisterActiveSession: (sessionId: string) => void; + isSessionActive: (sessionId: string) => boolean; + onStreamComplete: (callback: StreamCompleteCallback) => () => void; +} + +type ChatStore = ChatStoreState & ChatStoreActions; + +function notifyStreamComplete( + callbacks: Set, + sessionId: string, +) { + for (const callback of callbacks) { + try { + callback(sessionId); + } catch (err) { + console.warn("[ChatStore] Stream complete callback error:", err); + } + } +} + +function cleanupCompletedStreams(completedStreams: Map) { + const now = Date.now(); + for (const [sessionId, result] of completedStreams) { + if (now - result.completedAt > COMPLETED_STREAM_TTL) { + completedStreams.delete(sessionId); + } + } +} + +function moveToCompleted( + activeStreams: Map, + completedStreams: Map, + streamCompleteCallbacks: Set, + sessionId: string, +) { + const stream = activeStreams.get(sessionId); + if (!stream) return; + + const result: StreamResult = { + sessionId, + status: stream.status, + chunks: stream.chunks, + completedAt: Date.now(), + error: stream.error, + }; + + completedStreams.set(sessionId, result); + activeStreams.delete(sessionId); + cleanupCompletedStreams(completedStreams); + + if (stream.status === "completed" || stream.status === "error") { + notifyStreamComplete(streamCompleteCallbacks, sessionId); + } +} + +export const useChatStore = create((set, get) => ({ + activeStreams: new Map(), + completedStreams: new Map(), + activeSessions: new Set(), + streamCompleteCallbacks: new Set(), + + startStream: async function startStream( + sessionId, + message, + isUserMessage, + context, + onChunk, + ) { + const { activeStreams, completedStreams, streamCompleteCallbacks } = get(); + + const existingStream = activeStreams.get(sessionId); + if (existingStream) { + existingStream.abortController.abort(); + moveToCompleted( + activeStreams, + completedStreams, + streamCompleteCallbacks, + sessionId, + ); + } + + const abortController = new AbortController(); + const initialCallbacks = new Set<(chunk: StreamChunk) => void>(); + if (onChunk) initialCallbacks.add(onChunk); + + const stream: ActiveStream = { + sessionId, + abortController, + status: "streaming", + startedAt: Date.now(), + chunks: [], + onChunkCallbacks: initialCallbacks, + }; + + activeStreams.set(sessionId, stream); + + try { + await executeStream(stream, message, isUserMessage, context); + } finally { + if (onChunk) stream.onChunkCallbacks.delete(onChunk); + if (stream.status !== "streaming") { + moveToCompleted( + activeStreams, + completedStreams, + streamCompleteCallbacks, + sessionId, + ); + } + } + }, + + stopStream: function stopStream(sessionId) { + const { activeStreams, completedStreams, streamCompleteCallbacks } = get(); + const stream = activeStreams.get(sessionId); + if (stream) { + stream.abortController.abort(); + stream.status = "completed"; + moveToCompleted( + activeStreams, + completedStreams, + streamCompleteCallbacks, + sessionId, + ); + } + }, + + subscribeToStream: function subscribeToStream( + sessionId, + onChunk, + skipReplay = false, + ) { + const { activeStreams } = get(); + + const stream = activeStreams.get(sessionId); + if (stream) { + if (!skipReplay) { + for (const chunk of stream.chunks) { + onChunk(chunk); + } + } + stream.onChunkCallbacks.add(onChunk); + return function unsubscribe() { + stream.onChunkCallbacks.delete(onChunk); + }; + } + + return function noop() {}; + }, + + getStreamStatus: function getStreamStatus(sessionId) { + const { activeStreams, completedStreams } = get(); + + const active = activeStreams.get(sessionId); + if (active) return active.status; + + const completed = completedStreams.get(sessionId); + if (completed) return completed.status; + + return "idle"; + }, + + getCompletedStream: function getCompletedStream(sessionId) { + return get().completedStreams.get(sessionId); + }, + + clearCompletedStream: function clearCompletedStream(sessionId) { + get().completedStreams.delete(sessionId); + }, + + isStreaming: function isStreaming(sessionId) { + const stream = get().activeStreams.get(sessionId); + return stream?.status === "streaming"; + }, + + registerActiveSession: function registerActiveSession(sessionId) { + get().activeSessions.add(sessionId); + }, + + unregisterActiveSession: function unregisterActiveSession(sessionId) { + get().activeSessions.delete(sessionId); + }, + + isSessionActive: function isSessionActive(sessionId) { + return get().activeSessions.has(sessionId); + }, + + onStreamComplete: function onStreamComplete(callback) { + const { streamCompleteCallbacks } = get(); + streamCompleteCallbacks.add(callback); + return function unsubscribe() { + streamCompleteCallbacks.delete(callback); + }; + }, +})); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/chat-types.ts b/autogpt_platform/frontend/src/components/contextual/Chat/chat-types.ts new file mode 100644 index 0000000000..8c8aa7b704 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/chat-types.ts @@ -0,0 +1,94 @@ +import type { ToolArguments, ToolResult } from "@/types/chat"; + +export type StreamStatus = "idle" | "streaming" | "completed" | "error"; + +export interface StreamChunk { + type: + | "text_chunk" + | "text_ended" + | "tool_call" + | "tool_call_start" + | "tool_response" + | "login_needed" + | "need_login" + | "credentials_needed" + | "error" + | "usage" + | "stream_end"; + timestamp?: string; + content?: string; + message?: string; + code?: string; + details?: Record; + tool_id?: string; + tool_name?: string; + arguments?: ToolArguments; + result?: ToolResult; + success?: boolean; + idx?: number; + session_id?: string; + agent_info?: { + graph_id: string; + name: string; + trigger_type: string; + }; + provider?: string; + provider_name?: string; + credential_type?: string; + scopes?: string[]; + title?: string; + [key: string]: unknown; +} + +export type VercelStreamChunk = + | { type: "start"; messageId: string } + | { type: "finish" } + | { type: "text-start"; id: string } + | { type: "text-delta"; id: string; delta: string } + | { type: "text-end"; id: string } + | { type: "tool-input-start"; toolCallId: string; toolName: string } + | { + type: "tool-input-available"; + toolCallId: string; + toolName: string; + input: Record; + } + | { + type: "tool-output-available"; + toolCallId: string; + toolName?: string; + output: unknown; + success?: boolean; + } + | { + type: "usage"; + promptTokens: number; + completionTokens: number; + totalTokens: number; + } + | { + type: "error"; + errorText: string; + code?: string; + details?: Record; + }; + +export interface ActiveStream { + sessionId: string; + abortController: AbortController; + status: StreamStatus; + startedAt: number; + chunks: StreamChunk[]; + error?: Error; + onChunkCallbacks: Set<(chunk: StreamChunk) => void>; +} + +export interface StreamResult { + sessionId: string; + status: StreamStatus; + chunks: StreamChunk[]; + completedAt: number; + error?: Error; +} + +export type StreamCompleteCallback = (sessionId: string) => void; diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx index 17748f8dbc..f062df1397 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx @@ -4,6 +4,7 @@ import { Text } from "@/components/atoms/Text/Text"; import { Dialog } from "@/components/molecules/Dialog/Dialog"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { cn } from "@/lib/utils"; +import { GlobeHemisphereEastIcon } from "@phosphor-icons/react"; import { useEffect } from "react"; import { ChatInput } from "../ChatInput/ChatInput"; import { MessageList } from "../MessageList/MessageList"; @@ -55,24 +56,37 @@ export function ChatContainer({ )} > + + + Service unavailable + +
+ } controlled={{ isOpen: isRegionBlockedModalOpen, set: handleRegionModalOpenChange, }} onClose={handleRegionModalClose} + styling={{ maxWidth: 550, width: "100%", minWidth: "auto" }} > -
+
- This model is not available in your region. Please connect via VPN - and try again. + The Autogpt AI model is not available in your region or your + connection is blocking it. Please try again with a different + connection. -
+
diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/createStreamEventDispatcher.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/createStreamEventDispatcher.ts index 791cf046d5..82e9b05e88 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/createStreamEventDispatcher.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/createStreamEventDispatcher.ts @@ -1,5 +1,5 @@ import { toast } from "sonner"; -import { StreamChunk } from "../../useChatStream"; +import type { StreamChunk } from "../../chat-types"; import type { HandlerDependencies } from "./handlers"; import { handleError, diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts index 0edd1b411a..7dee924634 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/helpers.ts @@ -1,7 +1,118 @@ +import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse"; import { SessionKey, sessionStorage } from "@/services/storage/session-storage"; import type { ToolResult } from "@/types/chat"; import type { ChatMessageData } from "../ChatMessage/useChatMessage"; +export function processInitialMessages( + initialMessages: SessionDetailResponse["messages"], +): ChatMessageData[] { + const processedMessages: ChatMessageData[] = []; + const toolCallMap = new Map(); + + for (const msg of initialMessages) { + if (!isValidMessage(msg)) { + console.warn("Invalid message structure from backend:", msg); + continue; + } + + let content = String(msg.content || ""); + const role = String(msg.role || "assistant").toLowerCase(); + const toolCalls = msg.tool_calls; + const timestamp = msg.timestamp + ? new Date(msg.timestamp as string) + : undefined; + + if (role === "user") { + content = removePageContext(content); + if (!content.trim()) continue; + processedMessages.push({ + type: "message", + role: "user", + content, + timestamp, + }); + continue; + } + + if (role === "assistant") { + content = content + .replace(/[\s\S]*?<\/thinking>/gi, "") + .replace(/[\s\S]*?<\/internal_reasoning>/gi, "") + .trim(); + + if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) { + for (const toolCall of toolCalls) { + const toolName = toolCall.function.name; + const toolId = toolCall.id; + toolCallMap.set(toolId, toolName); + + try { + const args = JSON.parse(toolCall.function.arguments || "{}"); + processedMessages.push({ + type: "tool_call", + toolId, + toolName, + arguments: args, + timestamp, + }); + } catch (err) { + console.warn("Failed to parse tool call arguments:", err); + processedMessages.push({ + type: "tool_call", + toolId, + toolName, + arguments: {}, + timestamp, + }); + } + } + if (content.trim()) { + processedMessages.push({ + type: "message", + role: "assistant", + content, + timestamp, + }); + } + } else if (content.trim()) { + processedMessages.push({ + type: "message", + role: "assistant", + content, + timestamp, + }); + } + continue; + } + + if (role === "tool") { + const toolCallId = (msg.tool_call_id as string) || ""; + const toolName = toolCallMap.get(toolCallId) || "unknown"; + const toolResponse = parseToolResponse( + content, + toolCallId, + toolName, + timestamp, + ); + if (toolResponse) { + processedMessages.push(toolResponse); + } + continue; + } + + if (content.trim()) { + processedMessages.push({ + type: "message", + role: role as "user" | "assistant" | "system", + content, + timestamp, + }); + } + } + + return processedMessages; +} + export function hasSentInitialPrompt(sessionId: string): boolean { try { const sent = JSON.parse( diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts index 42dd04670d..b7f9d305dd 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts @@ -1,5 +1,6 @@ import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useChatStore } from "../../chat-store"; import { toast } from "sonner"; import { useChatStream } from "../../useChatStream"; import { usePageContext } from "../../usePageContext"; @@ -9,11 +10,8 @@ import { createUserMessage, filterAuthMessages, hasSentInitialPrompt, - isToolCallArray, - isValidMessage, markInitialPromptSent, - parseToolResponse, - removePageContext, + processInitialMessages, } from "./helpers"; interface Args { @@ -41,11 +39,18 @@ export function useChatContainer({ sendMessage: sendStreamMessage, stopStreaming, } = useChatStream(); + const activeStreams = useChatStore((s) => s.activeStreams); + const subscribeToStream = useChatStore((s) => s.subscribeToStream); const isStreaming = isStreamingInitiated || hasTextChunks; - useEffect(() => { - if (sessionId !== previousSessionIdRef.current) { - stopStreaming(previousSessionIdRef.current ?? undefined, true); + useEffect( + function handleSessionChange() { + if (sessionId === previousSessionIdRef.current) return; + + const prevSession = previousSessionIdRef.current; + if (prevSession) { + stopStreaming(prevSession); + } previousSessionIdRef.current = sessionId; setMessages([]); setStreamingChunks([]); @@ -53,138 +58,11 @@ export function useChatContainer({ setHasTextChunks(false); setIsStreamingInitiated(false); hasResponseRef.current = false; - } - }, [sessionId, stopStreaming]); - const allMessages = useMemo(() => { - const processedInitialMessages: ChatMessageData[] = []; - const toolCallMap = new Map(); + if (!sessionId) return; - for (const msg of initialMessages) { - if (!isValidMessage(msg)) { - console.warn("Invalid message structure from backend:", msg); - continue; - } - - let content = String(msg.content || ""); - const role = String(msg.role || "assistant").toLowerCase(); - const toolCalls = msg.tool_calls; - const timestamp = msg.timestamp - ? new Date(msg.timestamp as string) - : undefined; - - if (role === "user") { - content = removePageContext(content); - if (!content.trim()) continue; - processedInitialMessages.push({ - type: "message", - role: "user", - content, - timestamp, - }); - continue; - } - - if (role === "assistant") { - content = content - .replace(/[\s\S]*?<\/thinking>/gi, "") - .trim(); - - if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) { - for (const toolCall of toolCalls) { - const toolName = toolCall.function.name; - const toolId = toolCall.id; - toolCallMap.set(toolId, toolName); - - try { - const args = JSON.parse(toolCall.function.arguments || "{}"); - processedInitialMessages.push({ - type: "tool_call", - toolId, - toolName, - arguments: args, - timestamp, - }); - } catch (err) { - console.warn("Failed to parse tool call arguments:", err); - processedInitialMessages.push({ - type: "tool_call", - toolId, - toolName, - arguments: {}, - timestamp, - }); - } - } - if (content.trim()) { - processedInitialMessages.push({ - type: "message", - role: "assistant", - content, - timestamp, - }); - } - } else if (content.trim()) { - processedInitialMessages.push({ - type: "message", - role: "assistant", - content, - timestamp, - }); - } - continue; - } - - if (role === "tool") { - const toolCallId = (msg.tool_call_id as string) || ""; - const toolName = toolCallMap.get(toolCallId) || "unknown"; - const toolResponse = parseToolResponse( - content, - toolCallId, - toolName, - timestamp, - ); - if (toolResponse) { - processedInitialMessages.push(toolResponse); - } - continue; - } - - if (content.trim()) { - processedInitialMessages.push({ - type: "message", - role: role as "user" | "assistant" | "system", - content, - timestamp, - }); - } - } - - return [...processedInitialMessages, ...messages]; - }, [initialMessages, messages]); - - const sendMessage = useCallback( - async function sendMessage( - content: string, - isUserMessage: boolean = true, - context?: { url: string; content: string }, - ) { - if (!sessionId) { - console.error("[useChatContainer] Cannot send message: no session ID"); - return; - } - setIsRegionBlockedModalOpen(false); - if (isUserMessage) { - const userMessage = createUserMessage(content); - setMessages((prev) => [...filterAuthMessages(prev), userMessage]); - } else { - setMessages((prev) => filterAuthMessages(prev)); - } - setStreamingChunks([]); - streamingChunksRef.current = []; - setHasTextChunks(false); - setIsStreamingInitiated(true); - hasResponseRef.current = false; + const activeStream = activeStreams.get(sessionId); + if (!activeStream || activeStream.status !== "streaming") return; const dispatcher = createStreamEventDispatcher({ setHasTextChunks, @@ -197,42 +75,85 @@ export function useChatContainer({ setIsStreamingInitiated, }); - try { - await sendStreamMessage( - sessionId, - content, - dispatcher, - isUserMessage, - context, - ); - } catch (err) { - console.error("[useChatContainer] Failed to send message:", err); - setIsStreamingInitiated(false); - - // Don't show error toast for AbortError (expected during cleanup) - if (err instanceof Error && err.name === "AbortError") return; - - const errorMessage = - err instanceof Error ? err.message : "Failed to send message"; - toast.error("Failed to send message", { - description: errorMessage, - }); - } + setIsStreamingInitiated(true); + const skipReplay = initialMessages.length > 0; + return subscribeToStream(sessionId, dispatcher, skipReplay); }, - [sessionId, sendStreamMessage], + [sessionId, stopStreaming, activeStreams, subscribeToStream], ); - const handleStopStreaming = useCallback(() => { + const allMessages = useMemo( + () => [...processInitialMessages(initialMessages), ...messages], + [initialMessages, messages], + ); + + async function sendMessage( + content: string, + isUserMessage: boolean = true, + context?: { url: string; content: string }, + ) { + if (!sessionId) { + console.error("[useChatContainer] Cannot send message: no session ID"); + return; + } + setIsRegionBlockedModalOpen(false); + if (isUserMessage) { + const userMessage = createUserMessage(content); + setMessages((prev) => [...filterAuthMessages(prev), userMessage]); + } else { + setMessages((prev) => filterAuthMessages(prev)); + } + setStreamingChunks([]); + streamingChunksRef.current = []; + setHasTextChunks(false); + setIsStreamingInitiated(true); + hasResponseRef.current = false; + + const dispatcher = createStreamEventDispatcher({ + setHasTextChunks, + setStreamingChunks, + streamingChunksRef, + hasResponseRef, + setMessages, + setIsRegionBlockedModalOpen, + sessionId, + setIsStreamingInitiated, + }); + + try { + await sendStreamMessage( + sessionId, + content, + dispatcher, + isUserMessage, + context, + ); + } catch (err) { + console.error("[useChatContainer] Failed to send message:", err); + setIsStreamingInitiated(false); + + if (err instanceof Error && err.name === "AbortError") return; + + const errorMessage = + err instanceof Error ? err.message : "Failed to send message"; + toast.error("Failed to send message", { + description: errorMessage, + }); + } + } + + function handleStopStreaming() { stopStreaming(); setStreamingChunks([]); streamingChunksRef.current = []; setHasTextChunks(false); setIsStreamingInitiated(false); - }, [stopStreaming]); + } const { capturePageContext } = usePageContext(); + const sendMessageRef = useRef(sendMessage); + sendMessageRef.current = sendMessage; - // Send initial prompt if provided (for new sessions from homepage) useEffect( function handleInitialPrompt() { if (!initialPrompt || !sessionId) return; @@ -241,15 +162,9 @@ export function useChatContainer({ markInitialPromptSent(sessionId); const context = capturePageContext(); - sendMessage(initialPrompt, true, context); + sendMessageRef.current(initialPrompt, true, context); }, - [ - initialPrompt, - sessionId, - initialMessages.length, - sendMessage, - capturePageContext, - ], + [initialPrompt, sessionId, initialMessages.length, capturePageContext], ); async function sendMessageWithContext( diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx index 8cdecf0bf4..c45e8dc250 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx @@ -21,7 +21,7 @@ export function ChatInput({ className, }: Props) { const inputId = "chat-input"; - const { value, setValue, handleKeyDown, handleSend, hasMultipleLines } = + const { value, handleKeyDown, handleSubmit, handleChange, hasMultipleLines } = useChatInput({ onSend, disabled: disabled || isStreaming, @@ -29,15 +29,6 @@ export function ChatInput({ inputId, }); - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - handleSend(); - } - - function handleChange(e: React.ChangeEvent) { - setValue(e.target.value); - } - return (
diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts index 93d764b026..6fa8e7252b 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts @@ -1,4 +1,10 @@ -import { KeyboardEvent, useCallback, useEffect, useState } from "react"; +import { + ChangeEvent, + FormEvent, + KeyboardEvent, + useEffect, + useState, +} from "react"; interface UseChatInputArgs { onSend: (message: string) => void; @@ -16,6 +22,23 @@ export function useChatInput({ const [value, setValue] = useState(""); const [hasMultipleLines, setHasMultipleLines] = useState(false); + useEffect( + function focusOnMount() { + const textarea = document.getElementById(inputId) as HTMLTextAreaElement; + if (textarea) textarea.focus(); + }, + [inputId], + ); + + useEffect( + function focusWhenEnabled() { + if (disabled) return; + const textarea = document.getElementById(inputId) as HTMLTextAreaElement; + if (textarea) textarea.focus(); + }, + [disabled, inputId], + ); + useEffect(() => { const textarea = document.getElementById(inputId) as HTMLTextAreaElement; const wrapper = document.getElementById( @@ -77,7 +100,7 @@ export function useChatInput({ } }, [value, maxRows, inputId]); - const handleSend = useCallback(() => { + const handleSend = () => { if (disabled || !value.trim()) return; onSend(value.trim()); setValue(""); @@ -93,23 +116,31 @@ export function useChatInput({ wrapper.style.height = ""; wrapper.style.maxHeight = ""; } - }, [value, onSend, disabled, inputId]); + }; - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleSend(); - } - }, - [handleSend], - ); + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + handleSend(); + } + } + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + handleSend(); + } + + function handleChange(e: ChangeEvent) { + setValue(e.target.value); + } return { value, setValue, handleKeyDown, handleSend, + handleSubmit, + handleChange, hasMultipleLines, }; } diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/LastToolResponse/LastToolResponse.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/LastToolResponse/LastToolResponse.tsx index 3e6bf91ad2..15b10e5715 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/LastToolResponse/LastToolResponse.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MessageList/components/LastToolResponse/LastToolResponse.tsx @@ -1,7 +1,5 @@ -import { AIChatBubble } from "../../../AIChatBubble/AIChatBubble"; import type { ChatMessageData } from "../../../ChatMessage/useChatMessage"; -import { MarkdownContent } from "../../../MarkdownContent/MarkdownContent"; -import { formatToolResponse } from "../../../ToolResponseMessage/helpers"; +import { ToolResponseMessage } from "../../../ToolResponseMessage/ToolResponseMessage"; import { shouldSkipAgentOutput } from "../../helpers"; export interface LastToolResponseProps { @@ -15,16 +13,15 @@ export function LastToolResponse({ }: LastToolResponseProps) { if (message.type !== "tool_response") return null; - // Skip if this is an agent_output that should be rendered inside assistant message if (shouldSkipAgentOutput(message, prevMessage)) return null; - const formattedText = formatToolResponse(message.result, message.toolName); - return (
- - - +
); } diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx index 1ba10dd248..27da02beb8 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx @@ -1,7 +1,14 @@ +import { Text } from "@/components/atoms/Text/Text"; +import { cn } from "@/lib/utils"; import type { ToolResult } from "@/types/chat"; +import { WarningCircleIcon } from "@phosphor-icons/react"; import { AIChatBubble } from "../AIChatBubble/AIChatBubble"; import { MarkdownContent } from "../MarkdownContent/MarkdownContent"; -import { formatToolResponse } from "./helpers"; +import { + formatToolResponse, + getErrorMessage, + isErrorResponse, +} from "./helpers"; export interface ToolResponseMessageProps { toolId?: string; @@ -18,6 +25,24 @@ export function ToolResponseMessage({ success: _success, className, }: ToolResponseMessageProps) { + if (isErrorResponse(result)) { + const errorMessage = getErrorMessage(result); + return ( + +
+ + + {errorMessage} + +
+
+ ); + } + const formattedText = formatToolResponse(result, toolName); return ( diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/helpers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/helpers.ts index cf2bca95f7..400f32936e 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/helpers.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/helpers.ts @@ -1,3 +1,42 @@ +function stripInternalReasoning(content: string): string { + return content + .replace(/[\s\S]*?<\/internal_reasoning>/gi, "") + .replace(/[\s\S]*?<\/thinking>/gi, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function isErrorResponse(result: unknown): boolean { + if (typeof result === "string") { + const lower = result.toLowerCase(); + return ( + lower.startsWith("error:") || + lower.includes("not found") || + lower.includes("does not exist") || + lower.includes("failed to") || + lower.includes("unable to") + ); + } + if (typeof result === "object" && result !== null) { + const response = result as Record; + return response.type === "error" || response.error !== undefined; + } + return false; +} + +export function getErrorMessage(result: unknown): string { + if (typeof result === "string") { + return stripInternalReasoning(result.replace(/^error:\s*/i, "")); + } + if (typeof result === "object" && result !== null) { + const response = result as Record; + if (response.error) return stripInternalReasoning(String(response.error)); + if (response.message) + return stripInternalReasoning(String(response.message)); + } + return "An error occurred"; +} + function getToolCompletionPhrase(toolName: string): string { const toolCompletionPhrases: Record = { add_understanding: "Updated your business information", @@ -28,10 +67,10 @@ export function formatToolResponse(result: unknown, toolName: string): string { const parsed = JSON.parse(trimmed); return formatToolResponse(parsed, toolName); } catch { - return trimmed; + return stripInternalReasoning(trimmed); } } - return result; + return stripInternalReasoning(result); } if (typeof result !== "object" || result === null) { diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/stream-executor.ts b/autogpt_platform/frontend/src/components/contextual/Chat/stream-executor.ts new file mode 100644 index 0000000000..b0d970c286 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/stream-executor.ts @@ -0,0 +1,142 @@ +import type { + ActiveStream, + StreamChunk, + VercelStreamChunk, +} from "./chat-types"; +import { + INITIAL_RETRY_DELAY, + MAX_RETRIES, + normalizeStreamChunk, + parseSSELine, +} from "./stream-utils"; + +function notifySubscribers(stream: ActiveStream, chunk: StreamChunk) { + stream.chunks.push(chunk); + for (const callback of stream.onChunkCallbacks) { + try { + callback(chunk); + } catch (err) { + console.warn("[StreamExecutor] Subscriber callback error:", err); + } + } +} + +export async function executeStream( + stream: ActiveStream, + message: string, + isUserMessage: boolean, + context?: { url: string; content: string }, + retryCount: number = 0, +): Promise { + const { sessionId, abortController } = stream; + + try { + const url = `/api/chat/sessions/${sessionId}/stream`; + const body = JSON.stringify({ + message, + is_user_message: isUserMessage, + context: context || null, + }); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body, + signal: abortController.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `HTTP ${response.status}`); + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + notifySubscribers(stream, { type: "stream_end" }); + stream.status = "completed"; + return; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const data = parseSSELine(line); + if (data !== null) { + if (data === "[DONE]") { + notifySubscribers(stream, { type: "stream_end" }); + stream.status = "completed"; + return; + } + + try { + const rawChunk = JSON.parse(data) as + | StreamChunk + | VercelStreamChunk; + const chunk = normalizeStreamChunk(rawChunk); + if (!chunk) continue; + + notifySubscribers(stream, chunk); + + if (chunk.type === "stream_end") { + stream.status = "completed"; + return; + } + + if (chunk.type === "error") { + stream.status = "error"; + stream.error = new Error( + chunk.message || chunk.content || "Stream error", + ); + return; + } + } catch (err) { + console.warn("[StreamExecutor] Failed to parse SSE chunk:", err); + } + } + } + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + notifySubscribers(stream, { type: "stream_end" }); + stream.status = "completed"; + return; + } + + if (retryCount < MAX_RETRIES) { + const retryDelay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount); + console.log( + `[StreamExecutor] Retrying in ${retryDelay}ms (attempt ${retryCount + 1}/${MAX_RETRIES})`, + ); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + return executeStream( + stream, + message, + isUserMessage, + context, + retryCount + 1, + ); + } + + stream.status = "error"; + stream.error = err instanceof Error ? err : new Error("Stream failed"); + notifySubscribers(stream, { + type: "error", + message: stream.error.message, + }); + } +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/stream-utils.ts b/autogpt_platform/frontend/src/components/contextual/Chat/stream-utils.ts new file mode 100644 index 0000000000..4100926e79 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/stream-utils.ts @@ -0,0 +1,84 @@ +import type { ToolArguments, ToolResult } from "@/types/chat"; +import type { StreamChunk, VercelStreamChunk } from "./chat-types"; + +const LEGACY_STREAM_TYPES = new Set([ + "text_chunk", + "text_ended", + "tool_call", + "tool_call_start", + "tool_response", + "login_needed", + "need_login", + "credentials_needed", + "error", + "usage", + "stream_end", +]); + +export function isLegacyStreamChunk( + chunk: StreamChunk | VercelStreamChunk, +): chunk is StreamChunk { + return LEGACY_STREAM_TYPES.has(chunk.type as StreamChunk["type"]); +} + +export function normalizeStreamChunk( + chunk: StreamChunk | VercelStreamChunk, +): StreamChunk | null { + if (isLegacyStreamChunk(chunk)) return chunk; + + switch (chunk.type) { + case "text-delta": + return { type: "text_chunk", content: chunk.delta }; + case "text-end": + return { type: "text_ended" }; + case "tool-input-available": + return { + type: "tool_call_start", + tool_id: chunk.toolCallId, + tool_name: chunk.toolName, + arguments: chunk.input as ToolArguments, + }; + case "tool-output-available": + return { + type: "tool_response", + tool_id: chunk.toolCallId, + tool_name: chunk.toolName, + result: chunk.output as ToolResult, + success: chunk.success ?? true, + }; + case "usage": + return { + type: "usage", + promptTokens: chunk.promptTokens, + completionTokens: chunk.completionTokens, + totalTokens: chunk.totalTokens, + }; + case "error": + return { + type: "error", + message: chunk.errorText, + code: chunk.code, + details: chunk.details, + }; + case "finish": + return { type: "stream_end" }; + case "start": + case "text-start": + return null; + case "tool-input-start": + return { + type: "tool_call_start", + tool_id: chunk.toolCallId, + tool_name: chunk.toolName, + arguments: {}, + }; + } +} + +export const MAX_RETRIES = 3; +export const INITIAL_RETRY_DELAY = 1000; + +export function parseSSELine(line: string): string | null { + if (line.startsWith("data: ")) return line.slice(6); + return null; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts index cf629a287c..f6b2031059 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts @@ -2,7 +2,6 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; import { useChatSession } from "./useChatSession"; import { useChatStream } from "./useChatStream"; @@ -67,38 +66,16 @@ export function useChat({ urlSessionId }: UseChatArgs = {}) { ], ); - useEffect(() => { - if (isLoading || isCreating) { - const timer = setTimeout(() => { - setShowLoader(true); - }, 300); - return () => clearTimeout(timer); - } else { + useEffect( + function showLoaderWithDelay() { + if (isLoading || isCreating) { + const timer = setTimeout(() => setShowLoader(true), 300); + return () => clearTimeout(timer); + } setShowLoader(false); - } - }, [isLoading, isCreating]); - - useEffect(function monitorNetworkStatus() { - function handleOnline() { - toast.success("Connection restored", { - description: "You're back online", - }); - } - - function handleOffline() { - toast.error("You're offline", { - description: "Check your internet connection", - }); - } - - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); - - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); - }; - }, []); + }, + [isLoading, isCreating], + ); function clearSession() { clearSessionBase(); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChatDrawer.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChatDrawer.ts deleted file mode 100644 index 62e1a5a569..0000000000 --- a/autogpt_platform/frontend/src/components/contextual/Chat/useChatDrawer.ts +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { create } from "zustand"; - -interface ChatDrawerState { - isOpen: boolean; - open: () => void; - close: () => void; - toggle: () => void; -} - -export const useChatDrawer = create((set) => ({ - isOpen: false, - open: () => set({ isOpen: true }), - close: () => set({ isOpen: false }), - toggle: () => set((state) => ({ isOpen: !state.isOpen })), -})); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts index 553e348f79..dd743874f7 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts @@ -1,6 +1,7 @@ import { getGetV2GetSessionQueryKey, getGetV2GetSessionQueryOptions, + getGetV2ListSessionsQueryKey, postV2CreateSession, useGetV2GetSession, usePatchV2SessionAssignUser, @@ -101,6 +102,17 @@ export function useChatSession({ } }, [createError, loadError]); + useEffect( + function refreshSessionsListOnLoad() { + if (sessionId && sessionData && !isLoadingSession) { + queryClient.invalidateQueries({ + queryKey: getGetV2ListSessionsQueryKey(), + }); + } + }, + [sessionId, sessionData, isLoadingSession, queryClient], + ); + async function createSession() { try { setError(null); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChatStream.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChatStream.ts index 903c19cd30..5a9f637457 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/useChatStream.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChatStream.ts @@ -1,543 +1,110 @@ -import type { ToolArguments, ToolResult } from "@/types/chat"; -import { useCallback, useEffect, useRef, useState } from "react"; +"use client"; + +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { useChatStore } from "./chat-store"; +import type { StreamChunk } from "./chat-types"; -const MAX_RETRIES = 3; -const INITIAL_RETRY_DELAY = 1000; - -export interface StreamChunk { - type: - | "text_chunk" - | "text_ended" - | "tool_call" - | "tool_call_start" - | "tool_response" - | "login_needed" - | "need_login" - | "credentials_needed" - | "error" - | "usage" - | "stream_end"; - timestamp?: string; - content?: string; - message?: string; - code?: string; - details?: Record; - tool_id?: string; - tool_name?: string; - arguments?: ToolArguments; - result?: ToolResult; - success?: boolean; - idx?: number; - session_id?: string; - agent_info?: { - graph_id: string; - name: string; - trigger_type: string; - }; - provider?: string; - provider_name?: string; - credential_type?: string; - scopes?: string[]; - title?: string; - [key: string]: unknown; -} - -type VercelStreamChunk = - | { type: "start"; messageId: string } - | { type: "finish" } - | { type: "text-start"; id: string } - | { type: "text-delta"; id: string; delta: string } - | { type: "text-end"; id: string } - | { type: "tool-input-start"; toolCallId: string; toolName: string } - | { - type: "tool-input-available"; - toolCallId: string; - toolName: string; - input: ToolArguments; - } - | { - type: "tool-output-available"; - toolCallId: string; - toolName?: string; - output: ToolResult; - success?: boolean; - } - | { - type: "usage"; - promptTokens: number; - completionTokens: number; - totalTokens: number; - } - | { - type: "error"; - errorText: string; - code?: string; - details?: Record; - }; - -const LEGACY_STREAM_TYPES = new Set([ - "text_chunk", - "text_ended", - "tool_call", - "tool_call_start", - "tool_response", - "login_needed", - "need_login", - "credentials_needed", - "error", - "usage", - "stream_end", -]); - -function isLegacyStreamChunk( - chunk: StreamChunk | VercelStreamChunk, -): chunk is StreamChunk { - return LEGACY_STREAM_TYPES.has(chunk.type as StreamChunk["type"]); -} - -function normalizeStreamChunk( - chunk: StreamChunk | VercelStreamChunk, -): StreamChunk | null { - if (isLegacyStreamChunk(chunk)) { - return chunk; - } - switch (chunk.type) { - case "text-delta": - return { type: "text_chunk", content: chunk.delta }; - case "text-end": - return { type: "text_ended" }; - case "tool-input-available": - return { - type: "tool_call_start", - tool_id: chunk.toolCallId, - tool_name: chunk.toolName, - arguments: chunk.input, - }; - case "tool-output-available": - return { - type: "tool_response", - tool_id: chunk.toolCallId, - tool_name: chunk.toolName, - result: chunk.output, - success: chunk.success ?? true, - }; - case "usage": - return { - type: "usage", - promptTokens: chunk.promptTokens, - completionTokens: chunk.completionTokens, - totalTokens: chunk.totalTokens, - }; - case "error": - return { - type: "error", - message: chunk.errorText, - code: chunk.code, - details: chunk.details, - }; - case "finish": - return { type: "stream_end" }; - case "start": - case "text-start": - return null; - case "tool-input-start": - const toolInputStart = chunk as Extract< - VercelStreamChunk, - { type: "tool-input-start" } - >; - return { - type: "tool_call_start", - tool_id: toolInputStart.toolCallId, - tool_name: toolInputStart.toolName, - arguments: {}, - }; - } -} +export type { StreamChunk } from "./chat-types"; export function useChatStream() { const [isStreaming, setIsStreaming] = useState(false); const [error, setError] = useState(null); - const retryCountRef = useRef(0); - const retryTimeoutRef = useRef(null); - const abortControllerRef = useRef(null); const currentSessionIdRef = useRef(null); - const requestStartTimeRef = useRef(null); - - const stopStreaming = useCallback( - (sessionId?: string, force: boolean = false) => { - console.log("[useChatStream] stopStreaming called", { - hasAbortController: !!abortControllerRef.current, - isAborted: abortControllerRef.current?.signal.aborted, - currentSessionId: currentSessionIdRef.current, - requestedSessionId: sessionId, - requestStartTime: requestStartTimeRef.current, - timeSinceStart: requestStartTimeRef.current - ? Date.now() - requestStartTimeRef.current - : null, - force, - stack: new Error().stack, - }); - - if ( - sessionId && - currentSessionIdRef.current && - currentSessionIdRef.current !== sessionId - ) { - console.log( - "[useChatStream] Session changed, aborting previous stream", - { - oldSessionId: currentSessionIdRef.current, - newSessionId: sessionId, - }, - ); - } - - const controller = abortControllerRef.current; - if (controller) { - const timeSinceStart = requestStartTimeRef.current - ? Date.now() - requestStartTimeRef.current - : null; - - if (!force && timeSinceStart !== null && timeSinceStart < 100) { - console.log( - "[useChatStream] Request just started (<100ms), skipping abort to prevent race condition", - { - timeSinceStart, - }, - ); - return; - } - - try { - const signal = controller.signal; - - if ( - signal && - typeof signal.aborted === "boolean" && - !signal.aborted - ) { - console.log("[useChatStream] Aborting stream"); - controller.abort(); - } else { - console.log( - "[useChatStream] Stream already aborted or signal invalid", - ); - } - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - console.log( - "[useChatStream] AbortError caught (expected during cleanup)", - ); - } else { - console.warn("[useChatStream] Error aborting stream:", error); - } - } finally { - abortControllerRef.current = null; - requestStartTimeRef.current = null; - } - } - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - retryTimeoutRef.current = null; - } - setIsStreaming(false); - }, - [], + const onChunkCallbackRef = useRef<((chunk: StreamChunk) => void) | null>( + null, ); + const stopStream = useChatStore((s) => s.stopStream); + const unregisterActiveSession = useChatStore( + (s) => s.unregisterActiveSession, + ); + const isSessionActive = useChatStore((s) => s.isSessionActive); + const onStreamComplete = useChatStore((s) => s.onStreamComplete); + const getCompletedStream = useChatStore((s) => s.getCompletedStream); + const registerActiveSession = useChatStore((s) => s.registerActiveSession); + const startStream = useChatStore((s) => s.startStream); + const getStreamStatus = useChatStore((s) => s.getStreamStatus); + + function stopStreaming(sessionId?: string) { + const targetSession = sessionId || currentSessionIdRef.current; + if (targetSession) { + stopStream(targetSession); + unregisterActiveSession(targetSession); + } + setIsStreaming(false); + } + useEffect(() => { - console.log("[useChatStream] Component mounted"); - return () => { - const sessionIdAtUnmount = currentSessionIdRef.current; - console.log( - "[useChatStream] Component unmounting, calling stopStreaming", - { - sessionIdAtUnmount, - }, - ); - stopStreaming(undefined, false); + return function cleanup() { + const sessionId = currentSessionIdRef.current; + if (sessionId && !isSessionActive(sessionId)) { + stopStream(sessionId); + } currentSessionIdRef.current = null; + onChunkCallbackRef.current = null; }; - }, [stopStreaming]); + }, []); - const sendMessage = useCallback( - async ( - sessionId: string, - message: string, - onChunk: (chunk: StreamChunk) => void, - isUserMessage: boolean = true, - context?: { url: string; content: string }, - isRetry: boolean = false, - ) => { - console.log("[useChatStream] sendMessage called", { - sessionId, - message: message.substring(0, 50), - isUserMessage, - isRetry, - stack: new Error().stack, - }); + useEffect(() => { + const unsubscribe = onStreamComplete( + function handleStreamComplete(completedSessionId) { + if (completedSessionId !== currentSessionIdRef.current) return; - const previousSessionId = currentSessionIdRef.current; - stopStreaming(sessionId, true); - currentSessionIdRef.current = sessionId; - - const abortController = new AbortController(); - abortControllerRef.current = abortController; - requestStartTimeRef.current = Date.now(); - console.log("[useChatStream] Created new AbortController", { - sessionId, - previousSessionId, - requestStartTime: requestStartTimeRef.current, - }); - - if (abortController.signal.aborted) { - console.warn( - "[useChatStream] AbortController was aborted before request started", - ); - requestStartTimeRef.current = null; - return Promise.reject(new Error("Request aborted")); - } - - if (!isRetry) { - retryCountRef.current = 0; - } - setIsStreaming(true); - setError(null); - - try { - const url = `/api/chat/sessions/${sessionId}/stream`; - const body = JSON.stringify({ - message, - is_user_message: isUserMessage, - context: context || null, - }); - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", - }, - body, - signal: abortController.signal, - }); - - console.info("[useChatStream] Stream response", { - sessionId, - status: response.status, - ok: response.ok, - contentType: response.headers.get("content-type"), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.warn("[useChatStream] Stream response error", { - sessionId, - status: response.status, - errorText, - }); - throw new Error(errorText || `HTTP ${response.status}`); - } - - if (!response.body) { - console.warn("[useChatStream] Response body is null", { sessionId }); - throw new Error("Response body is null"); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let receivedChunkCount = 0; - let firstChunkAt: number | null = null; - let loggedLineCount = 0; - - return new Promise((resolve, reject) => { - let didDispatchStreamEnd = false; - - function dispatchStreamEnd() { - if (didDispatchStreamEnd) return; - didDispatchStreamEnd = true; - onChunk({ type: "stream_end" }); - } - - const cleanup = () => { - reader.cancel().catch(() => { - // Ignore cancel errors - }); - }; - - async function readStream() { - try { - while (true) { - const { done, value } = await reader.read(); - - if (done) { - cleanup(); - console.info("[useChatStream] Stream closed", { - sessionId, - receivedChunkCount, - timeSinceStart: requestStartTimeRef.current - ? Date.now() - requestStartTimeRef.current - : null, - }); - dispatchStreamEnd(); - retryCountRef.current = 0; - stopStreaming(); - resolve(); - return; - } - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6); - if (loggedLineCount < 3) { - console.info("[useChatStream] Raw stream line", { - sessionId, - data: - data.length > 300 ? `${data.slice(0, 300)}...` : data, - }); - loggedLineCount += 1; - } - if (data === "[DONE]") { - cleanup(); - console.info("[useChatStream] Stream done marker", { - sessionId, - receivedChunkCount, - timeSinceStart: requestStartTimeRef.current - ? Date.now() - requestStartTimeRef.current - : null, - }); - dispatchStreamEnd(); - retryCountRef.current = 0; - stopStreaming(); - resolve(); - return; - } - - try { - const rawChunk = JSON.parse(data) as - | StreamChunk - | VercelStreamChunk; - const chunk = normalizeStreamChunk(rawChunk); - if (!chunk) { - continue; - } - - if (!firstChunkAt) { - firstChunkAt = Date.now(); - console.info("[useChatStream] First stream chunk", { - sessionId, - chunkType: chunk.type, - timeSinceStart: requestStartTimeRef.current - ? firstChunkAt - requestStartTimeRef.current - : null, - }); - } - receivedChunkCount += 1; - - // Call the chunk handler - onChunk(chunk); - - // Handle stream lifecycle - if (chunk.type === "stream_end") { - didDispatchStreamEnd = true; - cleanup(); - console.info("[useChatStream] Stream end chunk", { - sessionId, - receivedChunkCount, - timeSinceStart: requestStartTimeRef.current - ? Date.now() - requestStartTimeRef.current - : null, - }); - retryCountRef.current = 0; - stopStreaming(); - resolve(); - return; - } else if (chunk.type === "error") { - cleanup(); - reject( - new Error( - chunk.message || chunk.content || "Stream error", - ), - ); - return; - } - } catch (err) { - // Skip invalid JSON lines - console.warn("Failed to parse SSE chunk:", err, data); - } - } - } - } - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - cleanup(); - dispatchStreamEnd(); - stopStreaming(); - resolve(); - return; - } - - const streamError = - err instanceof Error ? err : new Error("Failed to read stream"); - - if (retryCountRef.current < MAX_RETRIES) { - retryCountRef.current += 1; - const retryDelay = - INITIAL_RETRY_DELAY * Math.pow(2, retryCountRef.current - 1); - - toast.info("Connection interrupted", { - description: `Retrying in ${retryDelay / 1000} seconds...`, - }); - - retryTimeoutRef.current = setTimeout(() => { - sendMessage( - sessionId, - message, - onChunk, - isUserMessage, - context, - true, - ).catch((_err) => { - // Retry failed - }); - }, retryDelay); - } else { - setError(streamError); - toast.error("Connection Failed", { - description: - "Unable to connect to chat service. Please try again.", - }); - cleanup(); - dispatchStreamEnd(); - retryCountRef.current = 0; - stopStreaming(); - reject(streamError); - } - } - } - - readStream(); - }); - } catch (err) { - if (err instanceof Error && err.name === "AbortError") { - setIsStreaming(false); - return Promise.resolve(); - } - const streamError = - err instanceof Error ? err : new Error("Failed to start stream"); - setError(streamError); setIsStreaming(false); - throw streamError; + const completed = getCompletedStream(completedSessionId); + if (completed?.error) { + setError(completed.error); + } + unregisterActiveSession(completedSessionId); + }, + ); + + return unsubscribe; + }, []); + + async function sendMessage( + sessionId: string, + message: string, + onChunk: (chunk: StreamChunk) => void, + isUserMessage: boolean = true, + context?: { url: string; content: string }, + ) { + const previousSessionId = currentSessionIdRef.current; + if (previousSessionId && previousSessionId !== sessionId) { + stopStreaming(previousSessionId); + } + + currentSessionIdRef.current = sessionId; + onChunkCallbackRef.current = onChunk; + setIsStreaming(true); + setError(null); + + registerActiveSession(sessionId); + + try { + await startStream(sessionId, message, isUserMessage, context, onChunk); + + const status = getStreamStatus(sessionId); + if (status === "error") { + const completed = getCompletedStream(sessionId); + if (completed?.error) { + setError(completed.error); + toast.error("Connection Failed", { + description: "Unable to connect to chat service. Please try again.", + }); + throw completed.error; + } } - }, - [stopStreaming], - ); + } catch (err) { + const streamError = + err instanceof Error ? err : new Error("Failed to start stream"); + setError(streamError); + throw streamError; + } finally { + setIsStreaming(false); + } + } return { isStreaming, diff --git a/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx b/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx index 249d74596a..674f6c55eb 100644 --- a/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx +++ b/autogpt_platform/frontend/src/providers/posthog/posthog-provider.tsx @@ -9,11 +9,12 @@ import { ReactNode, useEffect, useRef } from "react"; export function PostHogProvider({ children }: { children: ReactNode }) { const isPostHogEnabled = environment.isPostHogEnabled(); + const postHogCredentials = environment.getPostHogCredentials(); useEffect(() => { - if (process.env.NEXT_PUBLIC_POSTHOG_KEY) { - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + if (postHogCredentials.key) { + posthog.init(postHogCredentials.key, { + api_host: postHogCredentials.host, defaults: "2025-11-30", capture_pageview: false, capture_pageleave: true, diff --git a/autogpt_platform/frontend/src/services/network-status/NetworkStatusMonitor.tsx b/autogpt_platform/frontend/src/services/network-status/NetworkStatusMonitor.tsx new file mode 100644 index 0000000000..7552bbf78c --- /dev/null +++ b/autogpt_platform/frontend/src/services/network-status/NetworkStatusMonitor.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { useNetworkStatus } from "./useNetworkStatus"; + +export function NetworkStatusMonitor() { + useNetworkStatus(); + return null; +} diff --git a/autogpt_platform/frontend/src/services/network-status/useNetworkStatus.ts b/autogpt_platform/frontend/src/services/network-status/useNetworkStatus.ts new file mode 100644 index 0000000000..472a6e0e90 --- /dev/null +++ b/autogpt_platform/frontend/src/services/network-status/useNetworkStatus.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { toast } from "sonner"; + +export function useNetworkStatus() { + useEffect(function monitorNetworkStatus() { + function handleOnline() { + toast.success("Connection restored", { + description: "You're back online", + }); + } + + function handleOffline() { + toast.error("You're offline", { + description: "Check your internet connection", + }); + } + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return function cleanup() { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); +} From 2134d777bef69cc9d64a11beb843d3165dadadb7 Mon Sep 17 00:00:00 2001 From: Swifty Date: Tue, 27 Jan 2026 16:21:13 +0100 Subject: [PATCH 19/36] fix(backend): exclude disabled blocks from chat search and indexing (#11854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Disabled blocks (e.g., webhook blocks without `platform_base_url` configured) were being indexed and returned in chat tool search results. This PR ensures they are properly filtered out. ### Changes šŸ—ļø - **find_block.py**: Skip disabled blocks when enriching search results - **content_handlers.py**: - Skip disabled blocks during embedding indexing - Update `get_stats()` to only count enabled blocks for accurate coverage metrics ### Why Blocks can be disabled for various reasons (missing OAuth config, no platform URL for webhooks, etc.). These blocks shouldn't appear in search results since users cannot use them. ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verified disabled blocks are filtered from search results - [x] Verified disabled blocks are not indexed - [x] Verified stats accurately reflect enabled block count --- .../api/features/chat/tools/find_block.py | 3 ++- .../api/features/store/content_handlers.py | 15 +++++++++++++-- .../features/store/content_handlers_test.py | 18 ++++++++++++++---- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/find_block.py b/autogpt_platform/backend/backend/api/features/chat/tools/find_block.py index a5e66f0a1c..7ca85961f9 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/find_block.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/find_block.py @@ -107,7 +107,8 @@ class FindBlockTool(BaseTool): block_id = result["content_id"] block = get_block(block_id) - if block: + # Skip disabled blocks + if block and not block.disabled: # Get input/output schemas input_schema = {} output_schema = {} diff --git a/autogpt_platform/backend/backend/api/features/store/content_handlers.py b/autogpt_platform/backend/backend/api/features/store/content_handlers.py index 1560db421c..cbbdcfbebf 100644 --- a/autogpt_platform/backend/backend/api/features/store/content_handlers.py +++ b/autogpt_platform/backend/backend/api/features/store/content_handlers.py @@ -188,6 +188,10 @@ class BlockHandler(ContentHandler): try: block_instance = block_cls() + # Skip disabled blocks - they shouldn't be indexed + if block_instance.disabled: + continue + # Build searchable text from block metadata parts = [] if hasattr(block_instance, "name") and block_instance.name: @@ -248,12 +252,19 @@ class BlockHandler(ContentHandler): from backend.data.block import get_blocks all_blocks = get_blocks() - total_blocks = len(all_blocks) + + # Filter out disabled blocks - they're not indexed + enabled_block_ids = [ + block_id + for block_id, block_cls in all_blocks.items() + if not block_cls().disabled + ] + total_blocks = len(enabled_block_ids) if total_blocks == 0: return {"total": 0, "with_embeddings": 0, "without_embeddings": 0} - block_ids = list(all_blocks.keys()) + block_ids = enabled_block_ids placeholders = ",".join([f"${i+1}" for i in range(len(block_ids))]) embedded_result = await query_raw_with_schema( diff --git a/autogpt_platform/backend/backend/api/features/store/content_handlers_test.py b/autogpt_platform/backend/backend/api/features/store/content_handlers_test.py index 28bc88e270..fee879fae0 100644 --- a/autogpt_platform/backend/backend/api/features/store/content_handlers_test.py +++ b/autogpt_platform/backend/backend/api/features/store/content_handlers_test.py @@ -81,6 +81,7 @@ async def test_block_handler_get_missing_items(mocker): mock_block_instance.name = "Calculator Block" mock_block_instance.description = "Performs calculations" mock_block_instance.categories = [MagicMock(value="MATH")] + mock_block_instance.disabled = False mock_block_instance.input_schema.model_json_schema.return_value = { "properties": {"expression": {"description": "Math expression to evaluate"}} } @@ -116,11 +117,18 @@ async def test_block_handler_get_stats(mocker): """Test BlockHandler returns correct stats.""" handler = BlockHandler() - # Mock get_blocks + # Mock get_blocks - each block class returns an instance with disabled=False + def make_mock_block_class(): + mock_class = MagicMock() + mock_instance = MagicMock() + mock_instance.disabled = False + mock_class.return_value = mock_instance + return mock_class + mock_blocks = { - "block-1": MagicMock(), - "block-2": MagicMock(), - "block-3": MagicMock(), + "block-1": make_mock_block_class(), + "block-2": make_mock_block_class(), + "block-3": make_mock_block_class(), } # Mock embedded count query (2 blocks have embeddings) @@ -309,6 +317,7 @@ async def test_block_handler_handles_missing_attributes(): mock_block_class = MagicMock() mock_block_instance = MagicMock() mock_block_instance.name = "Minimal Block" + mock_block_instance.disabled = False # No description, categories, or schema del mock_block_instance.description del mock_block_instance.categories @@ -342,6 +351,7 @@ async def test_block_handler_skips_failed_blocks(): good_instance.name = "Good Block" good_instance.description = "Works fine" good_instance.categories = [] + good_instance.disabled = False good_block.return_value = good_instance bad_block = MagicMock() From 071b3bb5cd3c85f99d12970e60f83672ceab1c08 Mon Sep 17 00:00:00 2001 From: Ubbe Date: Wed, 28 Jan 2026 00:49:28 +0700 Subject: [PATCH 20/36] fix(frontend): more copilot refinements (#11858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes šŸ—ļø On the **Copilot** page: - prevent unnecessary sidebar repaints - show a disclaimer when switching chats on the sidebar to terminate a current stream - handle loading better - save streams better when disconnecting ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Run the app locally and test the above --- .../components/CopilotShell/CopilotShell.tsx | 63 +++++- .../CopilotShell/useCopilotShell.ts | 106 +++++++++- .../(platform)/copilot/copilot-page-store.ts | 81 ++++++-- .../src/app/(platform)/copilot/page.tsx | 31 ++- .../app/(platform)/copilot/useCopilotPage.ts | 13 -- .../src/components/contextual/Chat/Chat.tsx | 21 +- .../components/contextual/Chat/chat-store.ts | 181 ++++++++++++------ .../components/ChatMessage/ChatMessage.tsx | 6 +- .../UserChatBubble/UserChatBubble.tsx | 2 +- .../contextual/Chat/useChatSession.ts | 12 -- 10 files changed, 376 insertions(+), 140 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx index fb22640302..8c9f9d528c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx @@ -3,7 +3,7 @@ import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader"; import { NAVBAR_HEIGHT_PX } from "@/lib/constants"; import type { ReactNode } from "react"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { useCopilotStore } from "../../copilot-page-store"; import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar"; import { LoadingState } from "./components/LoadingState/LoadingState"; @@ -25,10 +25,12 @@ export function CopilotShell({ children }: Props) { sessions, currentSessionId, handleSelectSession, + performSelectSession, handleOpenDrawer, handleCloseDrawer, handleDrawerOpenChange, handleNewChat, + performNewChat, hasNextPage, isFetchingNextPage, fetchNextPage, @@ -36,22 +38,71 @@ export function CopilotShell({ children }: Props) { } = useCopilotShell(); const setNewChatHandler = useCopilotStore((s) => s.setNewChatHandler); + const setNewChatWithInterruptHandler = useCopilotStore( + (s) => s.setNewChatWithInterruptHandler, + ); + const setSelectSessionHandler = useCopilotStore( + (s) => s.setSelectSessionHandler, + ); + const setSelectSessionWithInterruptHandler = useCopilotStore( + (s) => s.setSelectSessionWithInterruptHandler, + ); const requestNewChat = useCopilotStore((s) => s.requestNewChat); + const requestSelectSession = useCopilotStore((s) => s.requestSelectSession); + + const stableHandleNewChat = useCallback(handleNewChat, [handleNewChat]); + const stablePerformNewChat = useCallback(performNewChat, [performNewChat]); useEffect( - function registerNewChatHandler() { - setNewChatHandler(handleNewChat); + function registerNewChatHandlers() { + setNewChatHandler(stableHandleNewChat); + setNewChatWithInterruptHandler(stablePerformNewChat); return function cleanup() { setNewChatHandler(null); + setNewChatWithInterruptHandler(null); }; }, - [handleNewChat], + [ + stableHandleNewChat, + stablePerformNewChat, + setNewChatHandler, + setNewChatWithInterruptHandler, + ], + ); + + const stableHandleSelectSession = useCallback(handleSelectSession, [ + handleSelectSession, + ]); + + const stablePerformSelectSession = useCallback(performSelectSession, [ + performSelectSession, + ]); + + useEffect( + function registerSelectSessionHandlers() { + setSelectSessionHandler(stableHandleSelectSession); + setSelectSessionWithInterruptHandler(stablePerformSelectSession); + return function cleanup() { + setSelectSessionHandler(null); + setSelectSessionWithInterruptHandler(null); + }; + }, + [ + stableHandleSelectSession, + stablePerformSelectSession, + setSelectSessionHandler, + setSelectSessionWithInterruptHandler, + ], ); function handleNewChatClick() { requestNewChat(); } + function handleSessionClick(sessionId: string) { + requestSelectSession(sessionId); + } + if (!isLoggedIn) { return (
@@ -72,7 +123,7 @@ export function CopilotShell({ children }: Props) { isLoading={isLoading} hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} - onSelectSession={handleSelectSession} + onSelectSession={handleSessionClick} onFetchNextPage={fetchNextPage} onNewChat={handleNewChatClick} hasActiveSession={Boolean(hasActiveSession)} @@ -94,7 +145,7 @@ export function CopilotShell({ children }: Props) { isLoading={isLoading} hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} - onSelectSession={handleSelectSession} + onSelectSession={handleSessionClick} onFetchNextPage={fetchNextPage} onNewChat={handleNewChatClick} onClose={handleCloseDrawer} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts index a3aa0b55b2..3154df2975 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts @@ -1,17 +1,20 @@ "use client"; import { + getGetV2GetSessionQueryKey, getGetV2ListSessionsQueryKey, useGetV2GetSession, } from "@/app/api/__generated__/endpoints/chat/chat"; import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse"; import { okData } from "@/app/api/helpers"; +import { useChatStore } from "@/components/contextual/Chat/chat-store"; import { useBreakpoint } from "@/lib/hooks/useBreakpoint"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; import { useQueryClient } from "@tanstack/react-query"; import { parseAsString, useQueryState } from "nuqs"; import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { useCopilotStore } from "../../copilot-page-store"; import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer"; import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination"; import { @@ -73,6 +76,19 @@ export function useCopilotShell() { Map >(new Map()); + const [optimisticSessionId, setOptimisticSessionId] = useState( + null, + ); + + useEffect( + function clearOptimisticWhenUrlMatches() { + if (optimisticSessionId && currentSessionId === optimisticSessionId) { + setOptimisticSessionId(null); + } + }, + [currentSessionId, optimisticSessionId], + ); + // Mark as auto-selected when sessionId is in URL useEffect(() => { if (paramSessionId && !hasAutoSelectedRef.current) { @@ -142,7 +158,9 @@ export function useCopilotShell() { const visibleSessions = filterVisibleSessions(sessions); const sidebarSelectedSessionId = - isOnHomepage && !paramSessionId ? null : currentSessionId; + isOnHomepage && !paramSessionId && !optimisticSessionId + ? null + : optimisticSessionId || currentSessionId; const isReadyToShowContent = isOnHomepage ? true @@ -155,8 +173,89 @@ export function useCopilotShell() { hasAutoSelectedSession, ); - function handleSelectSession(sessionId: string) { + const stopStream = useChatStore((s) => s.stopStream); + const onStreamComplete = useChatStore((s) => s.onStreamComplete); + const setIsSwitchingSession = useCopilotStore((s) => s.setIsSwitchingSession); + + async function performSelectSession(sessionId: string) { + if (sessionId === currentSessionId) return; + + const sourceSessionId = currentSessionId; + + if (sourceSessionId) { + setIsSwitchingSession(true); + + await new Promise(function waitForStreamComplete(resolve) { + const unsubscribe = onStreamComplete( + function handleComplete(completedId) { + if (completedId === sourceSessionId) { + clearTimeout(timeout); + unsubscribe(); + resolve(); + } + }, + ); + const timeout = setTimeout(function handleTimeout() { + unsubscribe(); + resolve(); + }, 3000); + stopStream(sourceSessionId); + }); + + queryClient.invalidateQueries({ + queryKey: getGetV2GetSessionQueryKey(sourceSessionId), + }); + } + + setOptimisticSessionId(sessionId); setUrlSessionId(sessionId, { shallow: false }); + setIsSwitchingSession(false); + if (isMobile) handleCloseDrawer(); + } + + function handleSelectSession(sessionId: string) { + if (sessionId === currentSessionId) return; + setOptimisticSessionId(sessionId); + setUrlSessionId(sessionId, { shallow: false }); + if (isMobile) handleCloseDrawer(); + } + + async function performNewChat() { + const sourceSessionId = currentSessionId; + + if (sourceSessionId) { + setIsSwitchingSession(true); + + await new Promise(function waitForStreamComplete(resolve) { + const unsubscribe = onStreamComplete( + function handleComplete(completedId) { + if (completedId === sourceSessionId) { + clearTimeout(timeout); + unsubscribe(); + resolve(); + } + }, + ); + const timeout = setTimeout(function handleTimeout() { + unsubscribe(); + resolve(); + }, 3000); + stopStream(sourceSessionId); + }); + + queryClient.invalidateQueries({ + queryKey: getGetV2GetSessionQueryKey(sourceSessionId), + }); + setIsSwitchingSession(false); + } + + resetAutoSelect(); + resetPagination(); + queryClient.invalidateQueries({ + queryKey: getGetV2ListSessionsQueryKey(), + }); + setUrlSessionId(null, { shallow: false }); + setOptimisticSessionId(null); if (isMobile) handleCloseDrawer(); } @@ -167,6 +266,7 @@ export function useCopilotShell() { queryKey: getGetV2ListSessionsQueryKey(), }); setUrlSessionId(null, { shallow: false }); + setOptimisticSessionId(null); if (isMobile) handleCloseDrawer(); } @@ -187,10 +287,12 @@ export function useCopilotShell() { sessions: visibleSessions, currentSessionId: sidebarSelectedSessionId, handleSelectSession, + performSelectSession, handleOpenDrawer, handleCloseDrawer, handleDrawerOpenChange, handleNewChat, + performNewChat, hasNextPage, isFetchingNextPage: isSessionsFetching, fetchNextPage, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts index 22bf5000a1..486d31865b 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts @@ -4,51 +4,106 @@ import { create } from "zustand"; interface CopilotStoreState { isStreaming: boolean; - isNewChatModalOpen: boolean; + isSwitchingSession: boolean; + isInterruptModalOpen: boolean; + pendingAction: (() => void) | null; newChatHandler: (() => void) | null; + newChatWithInterruptHandler: (() => void) | null; + selectSessionHandler: ((sessionId: string) => void) | null; + selectSessionWithInterruptHandler: ((sessionId: string) => void) | null; } interface CopilotStoreActions { setIsStreaming: (isStreaming: boolean) => void; + setIsSwitchingSession: (isSwitchingSession: boolean) => void; setNewChatHandler: (handler: (() => void) | null) => void; + setNewChatWithInterruptHandler: (handler: (() => void) | null) => void; + setSelectSessionHandler: ( + handler: ((sessionId: string) => void) | null, + ) => void; + setSelectSessionWithInterruptHandler: ( + handler: ((sessionId: string) => void) | null, + ) => void; requestNewChat: () => void; - confirmNewChat: () => void; - cancelNewChat: () => void; + requestSelectSession: (sessionId: string) => void; + confirmInterrupt: () => void; + cancelInterrupt: () => void; } type CopilotStore = CopilotStoreState & CopilotStoreActions; export const useCopilotStore = create((set, get) => ({ isStreaming: false, - isNewChatModalOpen: false, + isSwitchingSession: false, + isInterruptModalOpen: false, + pendingAction: null, newChatHandler: null, + newChatWithInterruptHandler: null, + selectSessionHandler: null, + selectSessionWithInterruptHandler: null, setIsStreaming(isStreaming) { set({ isStreaming }); }, + setIsSwitchingSession(isSwitchingSession) { + set({ isSwitchingSession }); + }, + setNewChatHandler(handler) { set({ newChatHandler: handler }); }, + setNewChatWithInterruptHandler(handler) { + set({ newChatWithInterruptHandler: handler }); + }, + + setSelectSessionHandler(handler) { + set({ selectSessionHandler: handler }); + }, + + setSelectSessionWithInterruptHandler(handler) { + set({ selectSessionWithInterruptHandler: handler }); + }, + requestNewChat() { - const { isStreaming, newChatHandler } = get(); + const { isStreaming, newChatHandler, newChatWithInterruptHandler } = get(); if (isStreaming) { - set({ isNewChatModalOpen: true }); + if (!newChatWithInterruptHandler) return; + set({ + isInterruptModalOpen: true, + pendingAction: newChatWithInterruptHandler, + }); } else if (newChatHandler) { newChatHandler(); } }, - confirmNewChat() { - const { newChatHandler } = get(); - set({ isNewChatModalOpen: false }); - if (newChatHandler) { - newChatHandler(); + requestSelectSession(sessionId) { + const { + isStreaming, + selectSessionHandler, + selectSessionWithInterruptHandler, + } = get(); + if (isStreaming) { + if (!selectSessionWithInterruptHandler) return; + set({ + isInterruptModalOpen: true, + pendingAction: () => selectSessionWithInterruptHandler(sessionId), + }); + } else { + if (!selectSessionHandler) return; + selectSessionHandler(sessionId); } }, - cancelNewChat() { - set({ isNewChatModalOpen: false }); + confirmInterrupt() { + const { pendingAction } = get(); + set({ isInterruptModalOpen: false, pendingAction: null }); + if (pendingAction) pendingAction(); + }, + + cancelInterrupt() { + set({ isInterruptModalOpen: false, pendingAction: null }); }, })); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx index 83b21bf82e..008b06fcda 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx @@ -13,22 +13,15 @@ import { useCopilotPage } from "./useCopilotPage"; export default function CopilotPage() { const { state, handlers } = useCopilotPage(); - const confirmNewChat = useCopilotStore((s) => s.confirmNewChat); - const { - greetingName, - quickActions, - isLoading, - pageState, - isNewChatModalOpen, - isReady, - } = state; + const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen); + const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt); + const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt); + const { greetingName, quickActions, isLoading, pageState, isReady } = state; const { handleQuickAction, startChatWithPrompt, handleSessionNotFound, handleStreamingChange, - handleCancelNewChat, - handleNewChatModalOpen, } = handlers; if (!isReady) return null; @@ -48,31 +41,33 @@ export default function CopilotPage() { title="Interrupt current chat?" styling={{ maxWidth: 300, width: "100%" }} controlled={{ - isOpen: isNewChatModalOpen, - set: handleNewChatModalOpen, + isOpen: isInterruptModalOpen, + set: (open) => { + if (!open) cancelInterrupt(); + }, }} - onClose={handleCancelNewChat} + onClose={cancelInterrupt} >
The current chat response will be interrupted. Are you sure you - want to start a new chat? + want to continue?
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 1d9c843d7d..8cf4599a12 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -75,9 +75,7 @@ export function useCopilotPage() { const { user, isLoggedIn, isUserLoading } = useSupabase(); const { toast } = useToast(); - const isNewChatModalOpen = useCopilotStore((s) => s.isNewChatModalOpen); const setIsStreaming = useCopilotStore((s) => s.setIsStreaming); - const cancelNewChat = useCopilotStore((s) => s.cancelNewChat); const isChatEnabled = useGetFlag(Flag.CHAT); const flags = useFlags(); @@ -201,21 +199,12 @@ export function useCopilotPage() { setIsStreaming(isStreamingValue); } - function handleCancelNewChat() { - cancelNewChat(); - } - - function handleNewChatModalOpen(isOpen: boolean) { - if (!isOpen) cancelNewChat(); - } - return { state: { greetingName, quickActions, isLoading: isUserLoading, pageState: state.pageState, - isNewChatModalOpen, isReady: isFlagReady && isChatEnabled !== false && isLoggedIn, }, handlers: { @@ -223,8 +212,6 @@ export function useCopilotPage() { startChatWithPrompt, handleSessionNotFound, handleStreamingChange, - handleCancelNewChat, - handleNewChatModalOpen, }, }; } diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx index ba7584765d..a7a5f61674 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/Chat.tsx @@ -1,11 +1,12 @@ "use client"; +import { useCopilotStore } from "@/app/(platform)/copilot/copilot-page-store"; +import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; import { Text } from "@/components/atoms/Text/Text"; import { cn } from "@/lib/utils"; import { useEffect, useRef } from "react"; import { ChatContainer } from "./components/ChatContainer/ChatContainer"; import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState"; -import { ChatLoader } from "./components/ChatLoader/ChatLoader"; import { useChat } from "./useChat"; export interface ChatProps { @@ -24,6 +25,7 @@ export function Chat({ onStreamingChange, }: ChatProps) { const hasHandledNotFoundRef = useRef(false); + const isSwitchingSession = useCopilotStore((s) => s.isSwitchingSession); const { messages, isLoading, @@ -47,29 +49,34 @@ export function Chat({ [onSessionNotFound, urlSessionId, isSessionNotFound, isLoading, isCreating], ); + const shouldShowLoader = + (showLoader && (isLoading || isCreating)) || isSwitchingSession; + return (
{/* Main Content */}
{/* Loading State */} - {showLoader && (isLoading || isCreating) && ( + {shouldShowLoader && (
-
- +
+ - Loading your chats... + {isSwitchingSession + ? "Switching chat..." + : "Loading your chat..."}
)} {/* Error State */} - {error && !isLoading && ( + {error && !isLoading && !isSwitchingSession && ( )} {/* Session Content */} - {sessionId && !isLoading && !error && ( + {sessionId && !isLoading && !error && !isSwitchingSession && ( ) { +function cleanupExpiredStreams( + completedStreams: Map, +): Map { const now = Date.now(); - for (const [sessionId, result] of completedStreams) { + const cleaned = new Map(completedStreams); + for (const [sessionId, result] of cleaned) { if (now - result.completedAt > COMPLETED_STREAM_TTL) { - completedStreams.delete(sessionId); + cleaned.delete(sessionId); } } -} - -function moveToCompleted( - activeStreams: Map, - completedStreams: Map, - streamCompleteCallbacks: Set, - sessionId: string, -) { - const stream = activeStreams.get(sessionId); - if (!stream) return; - - const result: StreamResult = { - sessionId, - status: stream.status, - chunks: stream.chunks, - completedAt: Date.now(), - error: stream.error, - }; - - completedStreams.set(sessionId, result); - activeStreams.delete(sessionId); - cleanupCompletedStreams(completedStreams); - - if (stream.status === "completed" || stream.status === "error") { - notifyStreamComplete(streamCompleteCallbacks, sessionId); - } + return cleaned; } export const useChatStore = create((set, get) => ({ @@ -106,17 +84,31 @@ export const useChatStore = create((set, get) => ({ context, onChunk, ) { - const { activeStreams, completedStreams, streamCompleteCallbacks } = get(); + const state = get(); + const newActiveStreams = new Map(state.activeStreams); + let newCompletedStreams = new Map(state.completedStreams); + const callbacks = state.streamCompleteCallbacks; - const existingStream = activeStreams.get(sessionId); + const existingStream = newActiveStreams.get(sessionId); if (existingStream) { existingStream.abortController.abort(); - moveToCompleted( - activeStreams, - completedStreams, - streamCompleteCallbacks, + const normalizedStatus = + existingStream.status === "streaming" + ? "completed" + : existingStream.status; + const result: StreamResult = { sessionId, - ); + status: normalizedStatus, + chunks: existingStream.chunks, + completedAt: Date.now(), + error: existingStream.error, + }; + newCompletedStreams.set(sessionId, result); + newActiveStreams.delete(sessionId); + newCompletedStreams = cleanupExpiredStreams(newCompletedStreams); + if (normalizedStatus === "completed" || normalizedStatus === "error") { + notifyStreamComplete(callbacks, sessionId); + } } const abortController = new AbortController(); @@ -132,36 +124,76 @@ export const useChatStore = create((set, get) => ({ onChunkCallbacks: initialCallbacks, }; - activeStreams.set(sessionId, stream); + newActiveStreams.set(sessionId, stream); + set({ + activeStreams: newActiveStreams, + completedStreams: newCompletedStreams, + }); try { await executeStream(stream, message, isUserMessage, context); } finally { if (onChunk) stream.onChunkCallbacks.delete(onChunk); if (stream.status !== "streaming") { - moveToCompleted( - activeStreams, - completedStreams, - streamCompleteCallbacks, - sessionId, - ); + const currentState = get(); + const finalActiveStreams = new Map(currentState.activeStreams); + let finalCompletedStreams = new Map(currentState.completedStreams); + + const storedStream = finalActiveStreams.get(sessionId); + if (storedStream === stream) { + const result: StreamResult = { + sessionId, + status: stream.status, + chunks: stream.chunks, + completedAt: Date.now(), + error: stream.error, + }; + finalCompletedStreams.set(sessionId, result); + finalActiveStreams.delete(sessionId); + finalCompletedStreams = cleanupExpiredStreams(finalCompletedStreams); + set({ + activeStreams: finalActiveStreams, + completedStreams: finalCompletedStreams, + }); + if (stream.status === "completed" || stream.status === "error") { + notifyStreamComplete( + currentState.streamCompleteCallbacks, + sessionId, + ); + } + } } } }, stopStream: function stopStream(sessionId) { - const { activeStreams, completedStreams, streamCompleteCallbacks } = get(); - const stream = activeStreams.get(sessionId); - if (stream) { - stream.abortController.abort(); - stream.status = "completed"; - moveToCompleted( - activeStreams, - completedStreams, - streamCompleteCallbacks, - sessionId, - ); - } + const state = get(); + const stream = state.activeStreams.get(sessionId); + if (!stream) return; + + stream.abortController.abort(); + stream.status = "completed"; + + const newActiveStreams = new Map(state.activeStreams); + let newCompletedStreams = new Map(state.completedStreams); + + const result: StreamResult = { + sessionId, + status: stream.status, + chunks: stream.chunks, + completedAt: Date.now(), + error: stream.error, + }; + newCompletedStreams.set(sessionId, result); + newActiveStreams.delete(sessionId); + newCompletedStreams = cleanupExpiredStreams(newCompletedStreams); + + set({ + activeStreams: newActiveStreams, + completedStreams: newCompletedStreams, + }); + + notifyStreamComplete(state.streamCompleteCallbacks, sessionId); }, subscribeToStream: function subscribeToStream( @@ -169,16 +201,18 @@ export const useChatStore = create((set, get) => ({ onChunk, skipReplay = false, ) { - const { activeStreams } = get(); + const state = get(); + const stream = state.activeStreams.get(sessionId); - const stream = activeStreams.get(sessionId); if (stream) { if (!skipReplay) { for (const chunk of stream.chunks) { onChunk(chunk); } } + stream.onChunkCallbacks.add(onChunk); + return function unsubscribe() { stream.onChunkCallbacks.delete(onChunk); }; @@ -204,7 +238,12 @@ export const useChatStore = create((set, get) => ({ }, clearCompletedStream: function clearCompletedStream(sessionId) { - get().completedStreams.delete(sessionId); + const state = get(); + if (!state.completedStreams.has(sessionId)) return; + + const newCompletedStreams = new Map(state.completedStreams); + newCompletedStreams.delete(sessionId); + set({ completedStreams: newCompletedStreams }); }, isStreaming: function isStreaming(sessionId) { @@ -213,11 +252,21 @@ export const useChatStore = create((set, get) => ({ }, registerActiveSession: function registerActiveSession(sessionId) { - get().activeSessions.add(sessionId); + const state = get(); + if (state.activeSessions.has(sessionId)) return; + + const newActiveSessions = new Set(state.activeSessions); + newActiveSessions.add(sessionId); + set({ activeSessions: newActiveSessions }); }, unregisterActiveSession: function unregisterActiveSession(sessionId) { - get().activeSessions.delete(sessionId); + const state = get(); + if (!state.activeSessions.has(sessionId)) return; + + const newActiveSessions = new Set(state.activeSessions); + newActiveSessions.delete(sessionId); + set({ activeSessions: newActiveSessions }); }, isSessionActive: function isSessionActive(sessionId) { @@ -225,10 +274,16 @@ export const useChatStore = create((set, get) => ({ }, onStreamComplete: function onStreamComplete(callback) { - const { streamCompleteCallbacks } = get(); - streamCompleteCallbacks.add(callback); + const state = get(); + const newCallbacks = new Set(state.streamCompleteCallbacks); + newCallbacks.add(callback); + set({ streamCompleteCallbacks: newCallbacks }); + return function unsubscribe() { - streamCompleteCallbacks.delete(callback); + const currentState = get(); + const cleanedCallbacks = new Set(currentState.streamCompleteCallbacks); + cleanedCallbacks.delete(callback); + set({ streamCompleteCallbacks: cleanedCallbacks }); }; }, })); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx index 0fee33dbc0..29e3a60a8c 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatMessage/ChatMessage.tsx @@ -126,10 +126,6 @@ export function ChatMessage({ [displayContent, message], ); - function isLongResponse(content: string): boolean { - return content.split("\n").length > 5; - } - const handleTryAgain = useCallback(() => { if (message.type !== "message" || !onSendMessage) return; onSendMessage(message.content, message.role === "user"); @@ -358,7 +354,7 @@ export function ChatMessage({ )} - {!isUser && isFinalMessage && isLongResponse(displayContent) && ( + {!isUser && isFinalMessage && !isStreaming && (
diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx index f062df1397..dec221338a 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/ChatContainer.tsx @@ -16,6 +16,7 @@ export interface ChatContainerProps { initialPrompt?: string; className?: string; onStreamingChange?: (isStreaming: boolean) => void; + onOperationStarted?: () => void; } export function ChatContainer({ @@ -24,6 +25,7 @@ export function ChatContainer({ initialPrompt, className, onStreamingChange, + onOperationStarted, }: ChatContainerProps) { const { messages, @@ -38,6 +40,7 @@ export function ChatContainer({ sessionId, initialMessages, initialPrompt, + onOperationStarted, }); useEffect(() => { diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/handlers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/handlers.ts index f406d33db4..f3cac01f96 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/handlers.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/handlers.ts @@ -22,6 +22,7 @@ export interface HandlerDependencies { setIsStreamingInitiated: Dispatch>; setIsRegionBlockedModalOpen: Dispatch>; sessionId: string; + onOperationStarted?: () => void; } export function isRegionBlockedError(chunk: StreamChunk): boolean { @@ -163,6 +164,11 @@ export function handleToolResponse( } return; } + // Trigger polling when operation_started is received + if (responseMessage.type === "operation_started") { + deps.onOperationStarted?.(); + } + deps.setMessages((prev) => { const toolCallIndex = prev.findIndex( (msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id, diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts index 83730cc308..46f384d055 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatContainer/useChatContainer.ts @@ -14,16 +14,40 @@ import { processInitialMessages, } from "./helpers"; +// Helper to generate deduplication key for a message +function getMessageKey(msg: ChatMessageData): string { + if (msg.type === "message") { + // Don't include timestamp - dedupe by role + content only + // This handles the case where local and server timestamps differ + // Server messages are authoritative, so duplicates from local state are filtered + return `msg:${msg.role}:${msg.content}`; + } else if (msg.type === "tool_call") { + return `toolcall:${msg.toolId}`; + } else if (msg.type === "tool_response") { + return `toolresponse:${(msg as any).toolId}`; + } else if ( + msg.type === "operation_started" || + msg.type === "operation_pending" || + msg.type === "operation_in_progress" + ) { + return `op:${(msg as any).toolId || (msg as any).operationId || (msg as any).toolCallId || ""}:${msg.toolName}`; + } else { + return `${msg.type}:${JSON.stringify(msg).slice(0, 100)}`; + } +} + interface Args { sessionId: string | null; initialMessages: SessionDetailResponse["messages"]; initialPrompt?: string; + onOperationStarted?: () => void; } export function useChatContainer({ sessionId, initialMessages, initialPrompt, + onOperationStarted, }: Args) { const [messages, setMessages] = useState([]); const [streamingChunks, setStreamingChunks] = useState([]); @@ -73,13 +97,20 @@ export function useChatContainer({ setIsRegionBlockedModalOpen, sessionId, setIsStreamingInitiated, + onOperationStarted, }); setIsStreamingInitiated(true); const skipReplay = initialMessages.length > 0; return subscribeToStream(sessionId, dispatcher, skipReplay); }, - [sessionId, stopStreaming, activeStreams, subscribeToStream], + [ + sessionId, + stopStreaming, + activeStreams, + subscribeToStream, + onOperationStarted, + ], ); // Collect toolIds from completed tool results in initialMessages @@ -130,12 +161,19 @@ export function useChatContainer({ ); // Combine initial messages from backend with local streaming messages, - // then deduplicate to prevent duplicates when polling refreshes initialMessages + // Server messages maintain correct order; only append truly new local messages const allMessages = useMemo(() => { const processedInitial = processInitialMessages(initialMessages); - // Filter local messages to remove operation messages for completed tools - const filteredLocalMessages = messages.filter((msg) => { + // Build a set of keys from server messages for deduplication + const serverKeys = new Set(); + for (const msg of processedInitial) { + serverKeys.add(getMessageKey(msg)); + } + + // Filter local messages: remove duplicates and completed operation messages + const newLocalMessages = messages.filter((msg) => { + // Remove operation messages for completed tools if ( msg.type === "operation_started" || msg.type === "operation_pending" || @@ -143,48 +181,17 @@ export function useChatContainer({ ) { const toolId = (msg as any).toolId || (msg as any).toolCallId; if (toolId && completedToolIds.has(toolId)) { - return false; // Filter out - operation completed + return false; } } - return true; + // Remove messages that already exist in server data + const key = getMessageKey(msg); + return !serverKeys.has(key); }); - const combined = [...processedInitial, ...filteredLocalMessages]; - - // Deduplicate by content+role+timestamp. When initialMessages is refreshed via polling, - // it may contain messages that are also in the local `messages` state. - // Including timestamp prevents dropping legitimate repeated messages (e.g., user sends "yes" twice) - const seen = new Set(); - return combined.filter((msg) => { - // Create a key based on type, role, content, and timestamp for deduplication - let key: string; - if (msg.type === "message") { - // Use timestamp (rounded to nearest second) to allow slight variations - // while still catching true duplicates from SSE/polling overlap - const ts = msg.timestamp - ? Math.floor(new Date(msg.timestamp).getTime() / 1000) - : ""; - key = `msg:${msg.role}:${ts}:${msg.content}`; - } else if (msg.type === "tool_call") { - key = `toolcall:${msg.toolId}`; - } else if ( - msg.type === "operation_started" || - msg.type === "operation_pending" || - msg.type === "operation_in_progress" - ) { - // Dedupe operation messages by toolId or operationId - key = `op:${(msg as any).toolId || (msg as any).operationId || (msg as any).toolCallId || ""}:${msg.toolName}`; - } else { - // For other types, use a combination of type and first few fields - key = `${msg.type}:${JSON.stringify(msg).slice(0, 100)}`; - } - if (seen.has(key)) { - return false; - } - seen.add(key); - return true; - }); - }, [initialMessages, messages]); + // Server messages first (correct order), then new local messages + return [...processedInitial, ...newLocalMessages]; + }, [initialMessages, messages, completedToolIds]); async function sendMessage( content: string, @@ -217,6 +224,7 @@ export function useChatContainer({ setIsRegionBlockedModalOpen, sessionId, setIsStreamingInitiated, + onOperationStarted, }); try { diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts index f6b2031059..124301abc4 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChat.ts @@ -26,6 +26,7 @@ export function useChat({ urlSessionId }: UseChatArgs = {}) { claimSession, clearSession: clearSessionBase, loadSession, + startPollingForOperation, } = useChatSession({ urlSessionId, autoCreate: false, @@ -94,5 +95,6 @@ export function useChat({ urlSessionId }: UseChatArgs = {}) { loadSession, sessionId: sessionIdFromHook, showLoader, + startPollingForOperation, }; } diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts b/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts index 3fe4f801c6..936a49936c 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/useChatSession.ts @@ -103,9 +103,14 @@ export function useChatSession({ } }, [createError, loadError]); + // Track if we should be polling (set by external callers when they receive operation_started via SSE) + const [forcePolling, setForcePolling] = useState(false); + // Track if we've seen server acknowledge the pending operation (to avoid clearing forcePolling prematurely) + const hasSeenServerPendingRef = useRef(false); + // Check if there are any pending operations in the messages // Must check all operation types: operation_pending, operation_started, operation_in_progress - const hasPendingOperations = useMemo(() => { + const hasPendingOperationsFromServer = useMemo(() => { if (!messages || messages.length === 0) return false; const pendingTypes = new Set([ "operation_pending", @@ -126,6 +131,35 @@ export function useChatSession({ }); }, [messages]); + // Track when server has acknowledged the pending operation + useEffect(() => { + if (hasPendingOperationsFromServer) { + hasSeenServerPendingRef.current = true; + } + }, [hasPendingOperationsFromServer]); + + // Combined: poll if server has pending ops OR if we received operation_started via SSE + const hasPendingOperations = hasPendingOperationsFromServer || forcePolling; + + // Clear forcePolling only after server has acknowledged AND completed the operation + useEffect(() => { + if ( + forcePolling && + !hasPendingOperationsFromServer && + hasSeenServerPendingRef.current + ) { + // Server acknowledged the operation and it's now complete + setForcePolling(false); + hasSeenServerPendingRef.current = false; + } + }, [forcePolling, hasPendingOperationsFromServer]); + + // Function to trigger polling (called when operation_started is received via SSE) + function startPollingForOperation() { + setForcePolling(true); + hasSeenServerPendingRef.current = false; // Reset for new operation + } + // Refresh sessions list when a pending operation completes // (hasPendingOperations transitions from true to false) const prevHasPendingOperationsRef = useRef(hasPendingOperations); @@ -144,7 +178,8 @@ export function useChatSession({ [hasPendingOperations, sessionId, queryClient], ); - // Poll for updates when there are pending operations (long poll - 10s intervals with backoff) + // Poll for updates when there are pending operations + // Backoff: 2s, 4s, 6s, 8s, 10s, ... up to 30s max const pollAttemptRef = useRef(0); const hasPendingOperationsRef = useRef(hasPendingOperations); hasPendingOperationsRef.current = hasPendingOperations; @@ -159,27 +194,17 @@ export function useChatSession({ let cancelled = false; let timeoutId: ReturnType | null = null; - // Calculate delay with exponential backoff: 10s, 15s, 20s, 25s, 30s (max) - const baseDelay = 10000; - const maxDelay = 30000; - function schedule() { - const delay = Math.min( - baseDelay + pollAttemptRef.current * 5000, - maxDelay, - ); + // 2s, 4s, 6s, 8s, 10s, ... 30s (max) + const delay = Math.min((pollAttemptRef.current + 1) * 2000, 30000); timeoutId = setTimeout(async () => { if (cancelled) return; - console.info( - `[useChatSession] Polling for pending operation updates (attempt ${pollAttemptRef.current + 1})`, - ); pollAttemptRef.current += 1; try { await refetch(); } catch (err) { console.error("[useChatSession] Poll failed:", err); } finally { - // Continue polling if still pending and not cancelled if (!cancelled && hasPendingOperationsRef.current) { schedule(); } @@ -329,6 +354,7 @@ export function useChatSession({ refreshSession, claimSession, clearSession, + startPollingForOperation, }; } From 09539839441e86f0684ed6bc5bebefe7ab4bf03f Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Wed, 28 Jan 2026 01:22:46 -0600 Subject: [PATCH 28/36] feat(platform): disable onboarding redirects and add $5 signup bonus (#11862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disable automatic onboarding redirects on signup/login while keeping the checklist/wallet functional. Users now receive $5 (500 credits) on their first visit to /copilot. ### Changes šŸ—ļø - **Frontend**: `shouldShowOnboarding()` now returns `false`, disabling auto-redirects to `/onboarding` - **Backend**: Added `VISIT_COPILOT` onboarding step with 500 credit ($5) reward - **Frontend**: Copilot page automatically completes `VISIT_COPILOT` step on mount - **Database**: Migration to add `VISIT_COPILOT` to `OnboardingStep` enum NOTE: /onboarding/1-welcome -> /library now as shouldShowOnboardin is always false Users land directly on `/copilot` after signup/login and receive $5 invisibly (not shown in checklist UI). ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] New user signup (email/password) → lands on `/copilot`, wallet shows 500 credits - [x] Verified credits are only granted once (idempotent via onboarding reward mechanism) - [x] Existing user login (already granted flag set) → lands on `/copilot`, no duplicate credits - [x] Checklist/wallet remains functional #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) No configuration changes required. --- OPEN-2967 šŸ¤– Generated with [Claude Code](https://claude.ai/code) --- > [!NOTE] > Introduces a new onboarding step and adjusts onboarding flow. > > - Adds `VISIT_COPILOT` onboarding step (+500 credits) with DB enum migration and API/type updates > - Copilot page auto-completes `VISIT_COPILOT` on mount to grant the welcome bonus > - Changes `/onboarding/enabled` to require user context and return `false` when `CHAT` feature is enabled (skips legacy onboarding) > - Wallet now refreshes credits on any onboarding `step_completed` notification; confetti limited to visible tasks > - Test flows updated to accept redirects to `copilot`/`library` and verify authenticated state > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ec5a5a4dfdd76f8bd9b5918c38cee9b9d6832247. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Nicholas Tindle --- .../backend/backend/api/features/v1.py | 8 ++++-- .../backend/backend/data/onboarding.py | 4 +++ .../migration.sql | 2 ++ autogpt_platform/backend/schema.prisma | 1 + .../app/(platform)/copilot/useCopilotPage.ts | 9 +++++++ .../frontend/src/app/api/openapi.json | 2 ++ .../Navbar/components/Wallet/Wallet.tsx | 14 +++++++---- .../src/lib/autogpt-server-api/types.ts | 1 + .../frontend/src/tests/pages/login.page.ts | 6 ++++- .../frontend/src/tests/utils/signup.ts | 25 ++++++++++++------- 10 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 autogpt_platform/backend/migrations/20260127211502_add_visit_copilot_onboarding_step/migration.sql diff --git a/autogpt_platform/backend/backend/api/features/v1.py b/autogpt_platform/backend/backend/api/features/v1.py index 51789f9e2b..62b532089c 100644 --- a/autogpt_platform/backend/backend/api/features/v1.py +++ b/autogpt_platform/backend/backend/api/features/v1.py @@ -265,9 +265,13 @@ async def get_onboarding_agents( "/onboarding/enabled", summary="Is onboarding enabled", tags=["onboarding", "public"], - dependencies=[Security(requires_user)], ) -async def is_onboarding_enabled() -> bool: +async def is_onboarding_enabled( + user_id: Annotated[str, Security(get_user_id)], +) -> bool: + # If chat is enabled for user, skip legacy onboarding + if await is_feature_enabled(Flag.CHAT, user_id, False): + return False return await onboarding_enabled() diff --git a/autogpt_platform/backend/backend/data/onboarding.py b/autogpt_platform/backend/backend/data/onboarding.py index 6a842d1022..4af8e8dffd 100644 --- a/autogpt_platform/backend/backend/data/onboarding.py +++ b/autogpt_platform/backend/backend/data/onboarding.py @@ -41,6 +41,7 @@ FrontendOnboardingStep = Literal[ OnboardingStep.AGENT_NEW_RUN, OnboardingStep.AGENT_INPUT, OnboardingStep.CONGRATS, + OnboardingStep.VISIT_COPILOT, OnboardingStep.MARKETPLACE_VISIT, OnboardingStep.BUILDER_OPEN, ] @@ -122,6 +123,9 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate): async def _reward_user(user_id: str, onboarding: UserOnboarding, step: OnboardingStep): reward = 0 match step: + # Welcome bonus for visiting copilot ($5 = 500 credits) + case OnboardingStep.VISIT_COPILOT: + reward = 500 # Reward user when they clicked New Run during onboarding # This is because they need credits before scheduling a run (next step) # This is seen as a reward for the GET_RESULTS step in the wallet diff --git a/autogpt_platform/backend/migrations/20260127211502_add_visit_copilot_onboarding_step/migration.sql b/autogpt_platform/backend/migrations/20260127211502_add_visit_copilot_onboarding_step/migration.sql new file mode 100644 index 0000000000..6a08d9231b --- /dev/null +++ b/autogpt_platform/backend/migrations/20260127211502_add_visit_copilot_onboarding_step/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "OnboardingStep" ADD VALUE 'VISIT_COPILOT'; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index de94600820..2c52528e3f 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -81,6 +81,7 @@ enum OnboardingStep { AGENT_INPUT CONGRATS // First Wins + VISIT_COPILOT GET_RESULTS MARKETPLACE_VISIT MARKETPLACE_ADD_AGENT diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 38796946f4..e4713cd24a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -5,6 +5,7 @@ import { import { useToast } from "@/components/molecules/Toast/use-toast"; import { getHomepageRoute } from "@/lib/constants"; import { useSupabase } from "@/lib/supabase/hooks/useSupabase"; +import { useOnboarding } from "@/providers/onboarding/onboarding-provider"; import { Flag, type FlagValues, @@ -25,12 +26,20 @@ export function useCopilotPage() { const queryClient = useQueryClient(); const { user, isLoggedIn, isUserLoading } = useSupabase(); const { toast } = useToast(); + const { completeStep } = useOnboarding(); const { urlSessionId, setUrlSessionId } = useCopilotSessionId(); const setIsStreaming = useCopilotStore((s) => s.setIsStreaming); const isCreating = useCopilotStore((s) => s.isCreatingSession); const setIsCreating = useCopilotStore((s) => s.setIsCreatingSession); + // Complete VISIT_COPILOT onboarding step to grant $5 welcome bonus + useEffect(() => { + if (isLoggedIn) { + completeStep("VISIT_COPILOT"); + } + }, [completeStep, isLoggedIn]); + const isChatEnabled = useGetFlag(Flag.CHAT); const flags = useFlags(); const homepageRoute = getHomepageRoute(isChatEnabled); diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index b4e2bc80bd..d1ecd91702 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -4594,6 +4594,7 @@ "AGENT_NEW_RUN", "AGENT_INPUT", "CONGRATS", + "VISIT_COPILOT", "MARKETPLACE_VISIT", "BUILDER_OPEN" ], @@ -8754,6 +8755,7 @@ "AGENT_NEW_RUN", "AGENT_INPUT", "CONGRATS", + "VISIT_COPILOT", "GET_RESULTS", "MARKETPLACE_VISIT", "MARKETPLACE_ADD_AGENT", diff --git a/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/Wallet.tsx b/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/Wallet.tsx index 0a3c7de6c8..4a25c84f92 100644 --- a/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/Wallet.tsx +++ b/autogpt_platform/frontend/src/components/layout/Navbar/components/Wallet/Wallet.tsx @@ -255,13 +255,18 @@ export function Wallet() { (notification: WebSocketNotification) => { if ( notification.type !== "onboarding" || - notification.event !== "step_completed" || - !walletRef.current + notification.event !== "step_completed" ) { return; } - // Only trigger confetti for tasks that are in groups + // Always refresh credits when any onboarding step completes + fetchCredits(); + + // Only trigger confetti for tasks that are in displayed groups + if (!walletRef.current) { + return; + } const taskIds = groups .flatMap((group) => group.tasks) .map((task) => task.id); @@ -274,7 +279,6 @@ export function Wallet() { return; } - fetchCredits(); party.confetti(walletRef.current, { count: 30, spread: 120, @@ -284,7 +288,7 @@ export function Wallet() { modules: [fadeOut], }); }, - [fetchCredits, fadeOut], + [fetchCredits, fadeOut, groups], ); // WebSocket setup for onboarding notifications diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index 82c03bc9f1..2d583d2062 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -1003,6 +1003,7 @@ export type OnboardingStep = | "AGENT_INPUT" | "CONGRATS" // First Wins + | "VISIT_COPILOT" | "GET_RESULTS" | "MARKETPLACE_VISIT" | "MARKETPLACE_ADD_AGENT" diff --git a/autogpt_platform/frontend/src/tests/pages/login.page.ts b/autogpt_platform/frontend/src/tests/pages/login.page.ts index 9082cc6219..adcb8d908b 100644 --- a/autogpt_platform/frontend/src/tests/pages/login.page.ts +++ b/autogpt_platform/frontend/src/tests/pages/login.page.ts @@ -37,9 +37,13 @@ export class LoginPage { this.page.on("load", (page) => console.log(`ā„¹ļø Now at URL: ${page.url()}`)); // Start waiting for navigation before clicking + // Wait for redirect to marketplace, onboarding, library, or copilot (new landing pages) const leaveLoginPage = this.page .waitForURL( - (url) => /^\/(marketplace|onboarding(\/.*)?)?$/.test(url.pathname), + (url: URL) => + /^\/(marketplace|onboarding(\/.*)?|library|copilot)?$/.test( + url.pathname, + ), { timeout: 10_000 }, ) .catch((reason) => { diff --git a/autogpt_platform/frontend/src/tests/utils/signup.ts b/autogpt_platform/frontend/src/tests/utils/signup.ts index 7c8fdbe01b..192a9129b9 100644 --- a/autogpt_platform/frontend/src/tests/utils/signup.ts +++ b/autogpt_platform/frontend/src/tests/utils/signup.ts @@ -36,14 +36,16 @@ export async function signupTestUser( const signupButton = getButton("Sign up"); await signupButton.click(); - // Wait for successful signup - could redirect to onboarding or marketplace + // Wait for successful signup - could redirect to various pages depending on onboarding state try { - // Wait for either onboarding or marketplace redirect - await Promise.race([ - page.waitForURL(/\/onboarding/, { timeout: 15000 }), - page.waitForURL(/\/marketplace/, { timeout: 15000 }), - ]); + // Wait for redirect to onboarding, marketplace, copilot, or library + // Use a single waitForURL with a callback to avoid Promise.race race conditions + await page.waitForURL( + (url: URL) => + /\/(onboarding|marketplace|copilot|library)/.test(url.pathname), + { timeout: 15000 }, + ); } catch (error) { console.error( "āŒ Timeout waiting for redirect, current URL:", @@ -54,14 +56,19 @@ export async function signupTestUser( const currentUrl = page.url(); - // Handle onboarding or marketplace redirect + // Handle onboarding redirect if needed if (currentUrl.includes("/onboarding") && ignoreOnboarding) { await page.goto("http://localhost:3000/marketplace"); await page.waitForLoadState("domcontentloaded", { timeout: 10000 }); } - // Verify we're on the expected final page - if (ignoreOnboarding || currentUrl.includes("/marketplace")) { + // Verify we're on an expected final page and user is authenticated + if (currentUrl.includes("/copilot") || currentUrl.includes("/library")) { + // For copilot/library landing pages, just verify user is authenticated + await page + .getByTestId("profile-popout-menu-trigger") + .waitFor({ state: "visible", timeout: 10000 }); + } else if (ignoreOnboarding || currentUrl.includes("/marketplace")) { // Verify we're on marketplace await page .getByText( From d855f79874ae7a7846ea6f30bce96b78986727c0 Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Wed, 28 Jan 2026 12:28:27 -0600 Subject: [PATCH 29/36] fix(platform): reduce Sentry alert spam for expected errors (#11872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `InvalidInputError` for validation errors (search term too long, invalid pagination) - returns 400 instead of 500 - Remove redundant try/catch blocks in library routes - global exception handlers already handle `ValueError`→400 and `NotFoundError`→404 - Aggregate embedding backfill errors and log once at the end instead of per content type to prevent Sentry issue spam ## Test plan - [x] Verify validation errors (search term >100 chars) return 400 Bad Request - [x] Verify NotFoundError still returns 404 - [x] Verify embedding errors are logged once at the end with aggregated counts Fixes AUTOGPT-SERVER-7K5, BUILDER-6NC --------- Co-authored-by: Swifty --- .../backend/api/features/library/db.py | 8 +- .../api/features/library/routes/agents.py | 230 +++--------------- .../api/features/library/routes_test.py | 48 ---- .../backend/api/features/store/embeddings.py | 23 +- .../backend/backend/util/exceptions.py | 6 + .../frontend/src/app/api/openapi.json | 39 +-- 6 files changed, 67 insertions(+), 287 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/library/db.py b/autogpt_platform/backend/backend/api/features/library/db.py index 18d535d896..872fe66b28 100644 --- a/autogpt_platform/backend/backend/api/features/library/db.py +++ b/autogpt_platform/backend/backend/api/features/library/db.py @@ -21,7 +21,7 @@ from backend.data.model import CredentialsMetaInput from backend.integrations.creds_manager import IntegrationCredentialsManager from backend.integrations.webhooks.graph_lifecycle_hooks import on_graph_activate from backend.util.clients import get_scheduler_client -from backend.util.exceptions import DatabaseError, NotFoundError +from backend.util.exceptions import DatabaseError, InvalidInputError, NotFoundError from backend.util.json import SafeJson from backend.util.models import Pagination from backend.util.settings import Config @@ -64,11 +64,11 @@ async def list_library_agents( if page < 1 or page_size < 1: logger.warning(f"Invalid pagination: page={page}, page_size={page_size}") - raise DatabaseError("Invalid pagination input") + raise InvalidInputError("Invalid pagination input") if search_term and len(search_term.strip()) > 100: logger.warning(f"Search term too long: {repr(search_term)}") - raise DatabaseError("Search term is too long") + raise InvalidInputError("Search term is too long") where_clause: prisma.types.LibraryAgentWhereInput = { "userId": user_id, @@ -175,7 +175,7 @@ async def list_favorite_library_agents( if page < 1 or page_size < 1: logger.warning(f"Invalid pagination: page={page}, page_size={page_size}") - raise DatabaseError("Invalid pagination input") + raise InvalidInputError("Invalid pagination input") where_clause: prisma.types.LibraryAgentWhereInput = { "userId": user_id, diff --git a/autogpt_platform/backend/backend/api/features/library/routes/agents.py b/autogpt_platform/backend/backend/api/features/library/routes/agents.py index 38c34dd3b8..fa3d1a0f0c 100644 --- a/autogpt_platform/backend/backend/api/features/library/routes/agents.py +++ b/autogpt_platform/backend/backend/api/features/library/routes/agents.py @@ -1,4 +1,3 @@ -import logging from typing import Literal, Optional import autogpt_libs.auth as autogpt_auth_lib @@ -6,15 +5,11 @@ from fastapi import APIRouter, Body, HTTPException, Query, Security, status from fastapi.responses import Response from prisma.enums import OnboardingStep -import backend.api.features.store.exceptions as store_exceptions from backend.data.onboarding import complete_onboarding_step -from backend.util.exceptions import DatabaseError, NotFoundError from .. import db as library_db from .. import model as library_model -logger = logging.getLogger(__name__) - router = APIRouter( prefix="/agents", tags=["library", "private"], @@ -26,10 +21,6 @@ router = APIRouter( "", summary="List Library Agents", response_model=library_model.LibraryAgentResponse, - responses={ - 200: {"description": "List of library agents"}, - 500: {"description": "Server error", "content": {"application/json": {}}}, - }, ) async def list_library_agents( user_id: str = Security(autogpt_auth_lib.get_user_id), @@ -53,43 +44,19 @@ async def list_library_agents( ) -> library_model.LibraryAgentResponse: """ Get all agents in the user's library (both created and saved). - - Args: - user_id: ID of the authenticated user. - search_term: Optional search term to filter agents by name/description. - filter_by: List of filters to apply (favorites, created by user). - sort_by: List of sorting criteria (created date, updated date). - page: Page number to retrieve. - page_size: Number of agents per page. - - Returns: - A LibraryAgentResponse containing agents and pagination metadata. - - Raises: - HTTPException: If a server/database error occurs. """ - try: - return await library_db.list_library_agents( - user_id=user_id, - search_term=search_term, - sort_by=sort_by, - page=page, - page_size=page_size, - ) - except Exception as e: - logger.error(f"Could not list library agents for user #{user_id}: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e), - ) from e + return await library_db.list_library_agents( + user_id=user_id, + search_term=search_term, + sort_by=sort_by, + page=page, + page_size=page_size, + ) @router.get( "/favorites", summary="List Favorite Library Agents", - responses={ - 500: {"description": "Server error", "content": {"application/json": {}}}, - }, ) async def list_favorite_library_agents( user_id: str = Security(autogpt_auth_lib.get_user_id), @@ -106,30 +73,12 @@ async def list_favorite_library_agents( ) -> library_model.LibraryAgentResponse: """ Get all favorite agents in the user's library. - - Args: - user_id: ID of the authenticated user. - page: Page number to retrieve. - page_size: Number of agents per page. - - Returns: - A LibraryAgentResponse containing favorite agents and pagination metadata. - - Raises: - HTTPException: If a server/database error occurs. """ - try: - return await library_db.list_favorite_library_agents( - user_id=user_id, - page=page, - page_size=page_size, - ) - except Exception as e: - logger.error(f"Could not list favorite library agents for user #{user_id}: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e), - ) from e + return await library_db.list_favorite_library_agents( + user_id=user_id, + page=page, + page_size=page_size, + ) @router.get("/{library_agent_id}", summary="Get Library Agent") @@ -162,10 +111,6 @@ async def get_library_agent_by_graph_id( summary="Get Agent By Store ID", tags=["store", "library"], response_model=library_model.LibraryAgent | None, - responses={ - 200: {"description": "Library agent found"}, - 404: {"description": "Agent not found"}, - }, ) async def get_library_agent_by_store_listing_version_id( store_listing_version_id: str, @@ -174,32 +119,15 @@ async def get_library_agent_by_store_listing_version_id( """ Get Library Agent from Store Listing Version ID. """ - try: - return await library_db.get_library_agent_by_store_version_id( - store_listing_version_id, user_id - ) - except NotFoundError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e), - ) - except Exception as e: - logger.error(f"Could not fetch library agent from store version ID: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e), - ) from e + return await library_db.get_library_agent_by_store_version_id( + store_listing_version_id, user_id + ) @router.post( "", summary="Add Marketplace Agent", status_code=status.HTTP_201_CREATED, - responses={ - 201: {"description": "Agent added successfully"}, - 404: {"description": "Store listing version not found"}, - 500: {"description": "Server error"}, - }, ) async def add_marketplace_agent_to_library( store_listing_version_id: str = Body(embed=True), @@ -210,59 +138,19 @@ async def add_marketplace_agent_to_library( ) -> library_model.LibraryAgent: """ Add an agent from the marketplace to the user's library. - - Args: - store_listing_version_id: ID of the store listing version to add. - user_id: ID of the authenticated user. - - Returns: - library_model.LibraryAgent: Agent added to the library - - Raises: - HTTPException(404): If the listing version is not found. - HTTPException(500): If a server/database error occurs. """ - try: - agent = await library_db.add_store_agent_to_library( - store_listing_version_id=store_listing_version_id, - user_id=user_id, - ) - if source != "onboarding": - await complete_onboarding_step( - user_id, OnboardingStep.MARKETPLACE_ADD_AGENT - ) - return agent - - except store_exceptions.AgentNotFoundError as e: - logger.warning( - f"Could not find store listing version {store_listing_version_id} " - "to add to library" - ) - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) - except DatabaseError as e: - logger.error(f"Database error while adding agent to library: {e}", e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"message": str(e), "hint": "Inspect DB logs for details."}, - ) from e - except Exception as e: - logger.error(f"Unexpected error while adding agent to library: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={ - "message": str(e), - "hint": "Check server logs for more information.", - }, - ) from e + agent = await library_db.add_store_agent_to_library( + store_listing_version_id=store_listing_version_id, + user_id=user_id, + ) + if source != "onboarding": + await complete_onboarding_step(user_id, OnboardingStep.MARKETPLACE_ADD_AGENT) + return agent @router.patch( "/{library_agent_id}", summary="Update Library Agent", - responses={ - 200: {"description": "Agent updated successfully"}, - 500: {"description": "Server error"}, - }, ) async def update_library_agent( library_agent_id: str, @@ -271,52 +159,21 @@ async def update_library_agent( ) -> library_model.LibraryAgent: """ Update the library agent with the given fields. - - Args: - library_agent_id: ID of the library agent to update. - payload: Fields to update (auto_update_version, is_favorite, etc.). - user_id: ID of the authenticated user. - - Raises: - HTTPException(500): If a server/database error occurs. """ - try: - return await library_db.update_library_agent( - library_agent_id=library_agent_id, - user_id=user_id, - auto_update_version=payload.auto_update_version, - graph_version=payload.graph_version, - is_favorite=payload.is_favorite, - is_archived=payload.is_archived, - settings=payload.settings, - ) - except NotFoundError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e), - ) from e - except DatabaseError as e: - logger.error(f"Database error while updating library agent: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"message": str(e), "hint": "Verify DB connection."}, - ) from e - except Exception as e: - logger.error(f"Unexpected error while updating library agent: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"message": str(e), "hint": "Check server logs."}, - ) from e + return await library_db.update_library_agent( + library_agent_id=library_agent_id, + user_id=user_id, + auto_update_version=payload.auto_update_version, + graph_version=payload.graph_version, + is_favorite=payload.is_favorite, + is_archived=payload.is_archived, + settings=payload.settings, + ) @router.delete( "/{library_agent_id}", summary="Delete Library Agent", - responses={ - 204: {"description": "Agent deleted successfully"}, - 404: {"description": "Agent not found"}, - 500: {"description": "Server error"}, - }, ) async def delete_library_agent( library_agent_id: str, @@ -324,28 +181,11 @@ async def delete_library_agent( ) -> Response: """ Soft-delete the specified library agent. - - Args: - library_agent_id: ID of the library agent to delete. - user_id: ID of the authenticated user. - - Returns: - 204 No Content if successful. - - Raises: - HTTPException(404): If the agent does not exist. - HTTPException(500): If a server/database error occurs. """ - try: - await library_db.delete_library_agent( - library_agent_id=library_agent_id, user_id=user_id - ) - return Response(status_code=status.HTTP_204_NO_CONTENT) - except NotFoundError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e), - ) from e + await library_db.delete_library_agent( + library_agent_id=library_agent_id, user_id=user_id + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) @router.post("/{library_agent_id}/fork", summary="Fork Library Agent") diff --git a/autogpt_platform/backend/backend/api/features/library/routes_test.py b/autogpt_platform/backend/backend/api/features/library/routes_test.py index ca604af760..4d83812891 100644 --- a/autogpt_platform/backend/backend/api/features/library/routes_test.py +++ b/autogpt_platform/backend/backend/api/features/library/routes_test.py @@ -118,21 +118,6 @@ async def test_get_library_agents_success( ) -def test_get_library_agents_error(mocker: pytest_mock.MockFixture, test_user_id: str): - mock_db_call = mocker.patch("backend.api.features.library.db.list_library_agents") - mock_db_call.side_effect = Exception("Test error") - - response = client.get("/agents?search_term=test") - assert response.status_code == 500 - mock_db_call.assert_called_once_with( - user_id=test_user_id, - search_term="test", - sort_by=library_model.LibraryAgentSort.UPDATED_AT, - page=1, - page_size=15, - ) - - @pytest.mark.asyncio async def test_get_favorite_library_agents_success( mocker: pytest_mock.MockFixture, @@ -190,23 +175,6 @@ async def test_get_favorite_library_agents_success( ) -def test_get_favorite_library_agents_error( - mocker: pytest_mock.MockFixture, test_user_id: str -): - mock_db_call = mocker.patch( - "backend.api.features.library.db.list_favorite_library_agents" - ) - mock_db_call.side_effect = Exception("Test error") - - response = client.get("/agents/favorites") - assert response.status_code == 500 - mock_db_call.assert_called_once_with( - user_id=test_user_id, - page=1, - page_size=15, - ) - - def test_add_agent_to_library_success( mocker: pytest_mock.MockFixture, test_user_id: str ): @@ -258,19 +226,3 @@ def test_add_agent_to_library_success( store_listing_version_id="test-version-id", user_id=test_user_id ) mock_complete_onboarding.assert_awaited_once() - - -def test_add_agent_to_library_error(mocker: pytest_mock.MockFixture, test_user_id: str): - mock_db_call = mocker.patch( - "backend.api.features.library.db.add_store_agent_to_library" - ) - mock_db_call.side_effect = Exception("Test error") - - response = client.post( - "/agents", json={"store_listing_version_id": "test-version-id"} - ) - assert response.status_code == 500 - assert "detail" in response.json() # Verify error response structure - mock_db_call.assert_called_once_with( - store_listing_version_id="test-version-id", user_id=test_user_id - ) diff --git a/autogpt_platform/backend/backend/api/features/store/embeddings.py b/autogpt_platform/backend/backend/api/features/store/embeddings.py index 79a9a4e219..434f2fe2ce 100644 --- a/autogpt_platform/backend/backend/api/features/store/embeddings.py +++ b/autogpt_platform/backend/backend/api/features/store/embeddings.py @@ -454,6 +454,7 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]: total_processed = 0 total_success = 0 total_failed = 0 + all_errors: dict[str, int] = {} # Aggregate errors across all content types # Process content types in explicit order processing_order = [ @@ -499,23 +500,12 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]: success = sum(1 for result in results if result is True) failed = len(results) - success - # Aggregate unique errors to avoid Sentry spam + # Aggregate errors across all content types if failed > 0: - # Group errors by type and message - error_summary: dict[str, int] = {} for result in results: if isinstance(result, Exception): error_key = f"{type(result).__name__}: {str(result)}" - error_summary[error_key] = error_summary.get(error_key, 0) + 1 - - # Log aggregated error summary - error_details = ", ".join( - f"{error} ({count}x)" for error, count in error_summary.items() - ) - logger.error( - f"{content_type.value}: {failed}/{len(results)} embeddings failed. " - f"Errors: {error_details}" - ) + all_errors[error_key] = all_errors.get(error_key, 0) + 1 results_by_type[content_type.value] = { "processed": len(missing_items), @@ -542,6 +532,13 @@ async def backfill_all_content_types(batch_size: int = 10) -> dict[str, Any]: "error": str(e), } + # Log aggregated errors once at the end + if all_errors: + error_details = ", ".join( + f"{error} ({count}x)" for error, count in all_errors.items() + ) + logger.error(f"Embedding backfill errors: {error_details}") + return { "by_type": results_by_type, "totals": { diff --git a/autogpt_platform/backend/backend/util/exceptions.py b/autogpt_platform/backend/backend/util/exceptions.py index 6d0192c0e5..ffda783873 100644 --- a/autogpt_platform/backend/backend/util/exceptions.py +++ b/autogpt_platform/backend/backend/util/exceptions.py @@ -135,6 +135,12 @@ class GraphValidationError(ValueError): ) +class InvalidInputError(ValueError): + """Raised when user input validation fails (e.g., search term too long)""" + + pass + + class DatabaseError(Exception): """Raised when there is an error interacting with the database""" diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index d1ecd91702..a6fdded27f 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -3339,7 +3339,7 @@ "get": { "tags": ["v2", "library", "private"], "summary": "List Library Agents", - "description": "Get all agents in the user's library (both created and saved).\n\nArgs:\n user_id: ID of the authenticated user.\n search_term: Optional search term to filter agents by name/description.\n filter_by: List of filters to apply (favorites, created by user).\n sort_by: List of sorting criteria (created date, updated date).\n page: Page number to retrieve.\n page_size: Number of agents per page.\n\nReturns:\n A LibraryAgentResponse containing agents and pagination metadata.\n\nRaises:\n HTTPException: If a server/database error occurs.", + "description": "Get all agents in the user's library (both created and saved).", "operationId": "getV2List library agents", "security": [{ "HTTPBearerJWT": [] }], "parameters": [ @@ -3394,7 +3394,7 @@ ], "responses": { "200": { - "description": "List of library agents", + "description": "Successful Response", "content": { "application/json": { "schema": { @@ -3413,17 +3413,13 @@ "schema": { "$ref": "#/components/schemas/HTTPValidationError" } } } - }, - "500": { - "description": "Server error", - "content": { "application/json": {} } } } }, "post": { "tags": ["v2", "library", "private"], "summary": "Add Marketplace Agent", - "description": "Add an agent from the marketplace to the user's library.\n\nArgs:\n store_listing_version_id: ID of the store listing version to add.\n user_id: ID of the authenticated user.\n\nReturns:\n library_model.LibraryAgent: Agent added to the library\n\nRaises:\n HTTPException(404): If the listing version is not found.\n HTTPException(500): If a server/database error occurs.", + "description": "Add an agent from the marketplace to the user's library.", "operationId": "postV2Add marketplace agent", "security": [{ "HTTPBearerJWT": [] }], "requestBody": { @@ -3438,7 +3434,7 @@ }, "responses": { "201": { - "description": "Agent added successfully", + "description": "Successful Response", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LibraryAgent" } @@ -3448,7 +3444,6 @@ "401": { "$ref": "#/components/responses/HTTP401NotAuthenticatedError" }, - "404": { "description": "Store listing version not found" }, "422": { "description": "Validation Error", "content": { @@ -3456,8 +3451,7 @@ "schema": { "$ref": "#/components/schemas/HTTPValidationError" } } } - }, - "500": { "description": "Server error" } + } } } }, @@ -3511,7 +3505,7 @@ "get": { "tags": ["v2", "library", "private"], "summary": "List Favorite Library Agents", - "description": "Get all favorite agents in the user's library.\n\nArgs:\n user_id: ID of the authenticated user.\n page: Page number to retrieve.\n page_size: Number of agents per page.\n\nReturns:\n A LibraryAgentResponse containing favorite agents and pagination metadata.\n\nRaises:\n HTTPException: If a server/database error occurs.", + "description": "Get all favorite agents in the user's library.", "operationId": "getV2List favorite library agents", "security": [{ "HTTPBearerJWT": [] }], "parameters": [ @@ -3563,10 +3557,6 @@ "schema": { "$ref": "#/components/schemas/HTTPValidationError" } } } - }, - "500": { - "description": "Server error", - "content": { "application/json": {} } } } } @@ -3588,7 +3578,7 @@ ], "responses": { "200": { - "description": "Library agent found", + "description": "Successful Response", "content": { "application/json": { "schema": { @@ -3604,7 +3594,6 @@ "401": { "$ref": "#/components/responses/HTTP401NotAuthenticatedError" }, - "404": { "description": "Agent not found" }, "422": { "description": "Validation Error", "content": { @@ -3620,7 +3609,7 @@ "delete": { "tags": ["v2", "library", "private"], "summary": "Delete Library Agent", - "description": "Soft-delete the specified library agent.\n\nArgs:\n library_agent_id: ID of the library agent to delete.\n user_id: ID of the authenticated user.\n\nReturns:\n 204 No Content if successful.\n\nRaises:\n HTTPException(404): If the agent does not exist.\n HTTPException(500): If a server/database error occurs.", + "description": "Soft-delete the specified library agent.", "operationId": "deleteV2Delete library agent", "security": [{ "HTTPBearerJWT": [] }], "parameters": [ @@ -3636,11 +3625,9 @@ "description": "Successful Response", "content": { "application/json": { "schema": {} } } }, - "204": { "description": "Agent deleted successfully" }, "401": { "$ref": "#/components/responses/HTTP401NotAuthenticatedError" }, - "404": { "description": "Agent not found" }, "422": { "description": "Validation Error", "content": { @@ -3648,8 +3635,7 @@ "schema": { "$ref": "#/components/schemas/HTTPValidationError" } } } - }, - "500": { "description": "Server error" } + } } }, "get": { @@ -3690,7 +3676,7 @@ "patch": { "tags": ["v2", "library", "private"], "summary": "Update Library Agent", - "description": "Update the library agent with the given fields.\n\nArgs:\n library_agent_id: ID of the library agent to update.\n payload: Fields to update (auto_update_version, is_favorite, etc.).\n user_id: ID of the authenticated user.\n\nRaises:\n HTTPException(500): If a server/database error occurs.", + "description": "Update the library agent with the given fields.", "operationId": "patchV2Update library agent", "security": [{ "HTTPBearerJWT": [] }], "parameters": [ @@ -3713,7 +3699,7 @@ }, "responses": { "200": { - "description": "Agent updated successfully", + "description": "Successful Response", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LibraryAgent" } @@ -3730,8 +3716,7 @@ "schema": { "$ref": "#/components/schemas/HTTPValidationError" } } } - }, - "500": { "description": "Server error" } + } } } }, From e0dfae573288bfcdd367fbd1a284d70639ea8ea8 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Wed, 28 Jan 2026 14:58:02 -0600 Subject: [PATCH 30/36] fix(platform): evaluate chat flag after auth for correct redirect (#11873) Co-authored-by: Zamil Majdy Co-authored-by: Claude Opus 4.5 --- .../backend/backend/api/features/v1.py | 28 +++++++++++++++---- .../src/app/(no-navbar)/onboarding/page.tsx | 16 +++++++---- .../src/app/(platform)/auth/callback/route.ts | 11 ++++++-- .../src/app/(platform)/login/actions.ts | 11 ++++++-- .../src/app/(platform)/login/useLoginPage.ts | 9 ++---- .../src/app/(platform)/signup/actions.ts | 8 +++--- .../app/(platform)/signup/useSignupPage.ts | 1 - .../frontend/src/app/api/helpers.ts | 9 ++++-- .../frontend/src/app/api/openapi.json | 16 +++++++++-- 9 files changed, 75 insertions(+), 34 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/v1.py b/autogpt_platform/backend/backend/api/features/v1.py index 62b532089c..09d3759a65 100644 --- a/autogpt_platform/backend/backend/api/features/v1.py +++ b/autogpt_platform/backend/backend/api/features/v1.py @@ -261,18 +261,36 @@ async def get_onboarding_agents( return await get_recommended_agents(user_id) +class OnboardingStatusResponse(pydantic.BaseModel): + """Response for onboarding status check.""" + + is_onboarding_enabled: bool + is_chat_enabled: bool + + @v1_router.get( "/onboarding/enabled", summary="Is onboarding enabled", tags=["onboarding", "public"], + response_model=OnboardingStatusResponse, ) async def is_onboarding_enabled( user_id: Annotated[str, Security(get_user_id)], -) -> bool: - # If chat is enabled for user, skip legacy onboarding - if await is_feature_enabled(Flag.CHAT, user_id, False): - return False - return await onboarding_enabled() +) -> OnboardingStatusResponse: + # Check if chat is enabled for user + is_chat_enabled = await is_feature_enabled(Flag.CHAT, user_id, False) + + # If chat is enabled, skip legacy onboarding + if is_chat_enabled: + return OnboardingStatusResponse( + is_onboarding_enabled=False, + is_chat_enabled=True, + ) + + return OnboardingStatusResponse( + is_onboarding_enabled=await onboarding_enabled(), + is_chat_enabled=False, + ) @v1_router.post( diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx index 1ebfe6b87b..70d9783ccd 100644 --- a/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx +++ b/autogpt_platform/frontend/src/app/(no-navbar)/onboarding/page.tsx @@ -2,8 +2,9 @@ import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { resolveResponse, shouldShowOnboarding } from "@/app/api/helpers"; +import { resolveResponse, getOnboardingStatus } from "@/app/api/helpers"; import { getV1OnboardingState } from "@/app/api/__generated__/endpoints/onboarding/onboarding"; +import { getHomepageRoute } from "@/lib/constants"; export default function OnboardingPage() { const router = useRouter(); @@ -11,10 +12,13 @@ export default function OnboardingPage() { useEffect(() => { async function redirectToStep() { try { - // Check if onboarding is enabled - const isEnabled = await shouldShowOnboarding(); - if (!isEnabled) { - router.replace("/"); + // Check if onboarding is enabled (also gets chat flag for redirect) + const { shouldShowOnboarding, isChatEnabled } = + await getOnboardingStatus(); + const homepageRoute = getHomepageRoute(isChatEnabled); + + if (!shouldShowOnboarding) { + router.replace(homepageRoute); return; } @@ -22,7 +26,7 @@ export default function OnboardingPage() { // Handle completed onboarding if (onboarding.completedSteps.includes("GET_RESULTS")) { - router.replace("/"); + router.replace(homepageRoute); return; } diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts index a6a07a703f..15be137f63 100644 --- a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts +++ b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts @@ -1,8 +1,9 @@ import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; +import { getHomepageRoute } from "@/lib/constants"; import BackendAPI from "@/lib/autogpt-server-api"; import { NextResponse } from "next/server"; import { revalidatePath } from "next/cache"; -import { shouldShowOnboarding } from "@/app/api/helpers"; +import { getOnboardingStatus } from "@/app/api/helpers"; // Handle the callback to complete the user session login export async function GET(request: Request) { @@ -25,11 +26,15 @@ export async function GET(request: Request) { const api = new BackendAPI(); await api.createUser(); - if (await shouldShowOnboarding()) { + // Get onboarding status from backend (includes chat flag evaluated for this user) + const { shouldShowOnboarding, isChatEnabled } = + await getOnboardingStatus(); + if (shouldShowOnboarding) { next = "/onboarding"; revalidatePath("/onboarding", "layout"); } else { - revalidatePath("/", "layout"); + next = getHomepageRoute(isChatEnabled); + revalidatePath(next, "layout"); } } catch (createUserError) { console.error("Error creating user:", createUserError); diff --git a/autogpt_platform/frontend/src/app/(platform)/login/actions.ts b/autogpt_platform/frontend/src/app/(platform)/login/actions.ts index 936c879d69..447a25a41d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/login/actions.ts +++ b/autogpt_platform/frontend/src/app/(platform)/login/actions.ts @@ -1,10 +1,11 @@ "use server"; +import { getHomepageRoute } from "@/lib/constants"; import BackendAPI from "@/lib/autogpt-server-api"; import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; import { loginFormSchema } from "@/types/auth"; import * as Sentry from "@sentry/nextjs"; -import { shouldShowOnboarding } from "../../api/helpers"; +import { getOnboardingStatus } from "../../api/helpers"; export async function login(email: string, password: string) { try { @@ -36,11 +37,15 @@ export async function login(email: string, password: string) { const api = new BackendAPI(); await api.createUser(); - const onboarding = await shouldShowOnboarding(); + // Get onboarding status from backend (includes chat flag evaluated for this user) + const { shouldShowOnboarding, isChatEnabled } = await getOnboardingStatus(); + const next = shouldShowOnboarding + ? "/onboarding" + : getHomepageRoute(isChatEnabled); return { success: true, - onboarding, + next, }; } catch (err) { Sentry.captureException(err); diff --git a/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts b/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts index 9bde570548..e64cc1858d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/login/useLoginPage.ts @@ -97,13 +97,8 @@ export function useLoginPage() { throw new Error(result.error || "Login failed"); } - if (nextUrl) { - router.replace(nextUrl); - } else if (result.onboarding) { - router.replace("/onboarding"); - } else { - router.replace(homepageRoute); - } + // Prefer URL's next parameter, then use backend-determined route + router.replace(nextUrl || result.next || homepageRoute); } catch (error) { toast({ title: diff --git a/autogpt_platform/frontend/src/app/(platform)/signup/actions.ts b/autogpt_platform/frontend/src/app/(platform)/signup/actions.ts index 6d68782e7a..0fbba54b8e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/signup/actions.ts +++ b/autogpt_platform/frontend/src/app/(platform)/signup/actions.ts @@ -5,14 +5,13 @@ import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase"; import { signupFormSchema } from "@/types/auth"; import * as Sentry from "@sentry/nextjs"; import { isWaitlistError, logWaitlistError } from "../../api/auth/utils"; -import { shouldShowOnboarding } from "../../api/helpers"; +import { getOnboardingStatus } from "../../api/helpers"; export async function signup( email: string, password: string, confirmPassword: string, agreeToTerms: boolean, - isChatEnabled: boolean, ) { try { const parsed = signupFormSchema.safeParse({ @@ -59,8 +58,9 @@ export async function signup( await supabase.auth.setSession(data.session); } - const isOnboardingEnabled = await shouldShowOnboarding(); - const next = isOnboardingEnabled + // Get onboarding status from backend (includes chat flag evaluated for this user) + const { shouldShowOnboarding, isChatEnabled } = await getOnboardingStatus(); + const next = shouldShowOnboarding ? "/onboarding" : getHomepageRoute(isChatEnabled); diff --git a/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts b/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts index 5bd53ca846..5fa8c2c159 100644 --- a/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/signup/useSignupPage.ts @@ -108,7 +108,6 @@ export function useSignupPage() { data.password, data.confirmPassword, data.agreeToTerms, - isChatEnabled === true, ); setIsLoading(false); diff --git a/autogpt_platform/frontend/src/app/api/helpers.ts b/autogpt_platform/frontend/src/app/api/helpers.ts index e9a708ba4c..c2104d231a 100644 --- a/autogpt_platform/frontend/src/app/api/helpers.ts +++ b/autogpt_platform/frontend/src/app/api/helpers.ts @@ -175,9 +175,12 @@ export async function resolveResponse< return res.data; } -export async function shouldShowOnboarding() { - const isEnabled = await resolveResponse(getV1IsOnboardingEnabled()); +export async function getOnboardingStatus() { + const status = await resolveResponse(getV1IsOnboardingEnabled()); const onboarding = await resolveResponse(getV1OnboardingState()); const isCompleted = onboarding.completedSteps.includes("CONGRATS"); - return isEnabled && !isCompleted; + return { + shouldShowOnboarding: status.is_onboarding_enabled && !isCompleted, + isChatEnabled: status.is_chat_enabled, + }; } diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index a6fdded27f..2a9db1990d 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -4525,8 +4525,7 @@ "content": { "application/json": { "schema": { - "type": "boolean", - "title": "Response Getv1Is Onboarding Enabled" + "$ref": "#/components/schemas/OnboardingStatusResponse" } } } @@ -8730,6 +8729,19 @@ "title": "OAuthApplicationPublicInfo", "description": "Public information about an OAuth application (for consent screen)" }, + "OnboardingStatusResponse": { + "properties": { + "is_onboarding_enabled": { + "type": "boolean", + "title": "Is Onboarding Enabled" + }, + "is_chat_enabled": { "type": "boolean", "title": "Is Chat Enabled" } + }, + "type": "object", + "required": ["is_onboarding_enabled", "is_chat_enabled"], + "title": "OnboardingStatusResponse", + "description": "Response for onboarding status check." + }, "OnboardingStep": { "type": "string", "enum": [ From 7668c17d9cb59633a73cfbaf3b7854439a013113 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Wed, 28 Jan 2026 23:49:47 -0600 Subject: [PATCH 31/36] feat(platform): add User Workspace for persistent CoPilot file storage (#11867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements persistent User Workspace storage for CoPilot, enabling blocks to save and retrieve files across sessions. Files are stored in session-scoped virtual paths (`/sessions/{session_id}/`). Fixes SECRT-1833 ### Changes šŸ—ļø **Database & Storage:** - Add `UserWorkspace` and `UserWorkspaceFile` Prisma models - Implement `WorkspaceStorageBackend` abstraction (GCS for cloud, local filesystem for self-hosted) - Add `workspace_id` and `session_id` fields to `ExecutionContext` **Backend API:** - Add REST endpoints: `GET/POST /api/workspace/files`, `GET/DELETE /api/workspace/files/{id}`, `GET /api/workspace/files/{id}/download` - Add CoPilot tools: `list_workspace_files`, `read_workspace_file`, `write_workspace_file` - Integrate workspace storage into `store_media_file()` - returns `workspace://file-id` references **Block Updates:** - Refactor all file-handling blocks to use unified `ExecutionContext` parameter - Update media-generating blocks to persist outputs to workspace (AIImageGenerator, AIImageCustomizer, FluxKontext, TalkingHead, FAL video, Bannerbear, etc.) **Frontend:** - Render `workspace://` image references in chat via proxy endpoint - Add "AI cannot see this image" overlay indicator **CoPilot Context Mapping:** - Session = Agent (graph_id) = Run (graph_exec_id) - Files scoped to `/sessions/{session_id}/` ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [ ] I have tested my changes according to the test plan: - [ ] Create CoPilot session, generate image with AIImageGeneratorBlock - [ ] Verify image returns `workspace://file-id` (not base64) - [ ] Verify image renders in chat with visibility indicator - [ ] Verify workspace files persist across sessions - [ ] Test list/read/write workspace files via CoPilot tools - [ ] Test local storage backend for self-hosted deployments #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) šŸ¤– Generated with [Claude Code](https://claude.ai/code) --- > [!NOTE] > **Medium Risk** > Introduces a new persistent file-storage surface area (DB tables, storage backends, download API, and chat tools) and rewires `store_media_file()`/block execution context across many blocks, so regressions could impact file handling, access control, or storage costs. > > **Overview** > Adds a **persistent per-user Workspace** (new `UserWorkspace`/`UserWorkspaceFile` models plus `WorkspaceManager` + `WorkspaceStorageBackend` with GCS/local implementations) and wires it into the API via a new `/api/workspace/files/{file_id}/download` route (including header-sanitized `Content-Disposition`) and shutdown lifecycle hooks. > > Extends `ExecutionContext` to carry execution identity + `workspace_id`/`session_id`, updates executor tooling to clone node-specific contexts, and updates `run_block` (CoPilot) to create a session-scoped workspace and synthetic graph/run/node IDs. > > Refactors `store_media_file()` to require `execution_context` + `return_format` and to support `workspace://` references; migrates many media/file-handling blocks and related tests to the new API and to persist generated media as `workspace://...` (or fall back to data URIs outside CoPilot), and adds CoPilot chat tools for listing/reading/writing/deleting workspace files with safeguards against context bloat. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6abc70f7931ec1d8d4e9b99c49d606b21bf740fa. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Reinier van der Leer --- autogpt_platform/CLAUDE.md | 44 ++ .../backend/api/features/chat/tools/IDEAS.md | 79 +++ .../api/features/chat/tools/__init__.py | 11 + .../backend/api/features/chat/tools/models.py | 6 + .../api/features/chat/tools/run_block.py | 45 +- .../features/chat/tools/workspace_files.py | 620 ++++++++++++++++++ .../api/features/workspace/__init__.py | 1 + .../backend/api/features/workspace/routes.py | 122 ++++ .../backend/backend/api/rest_api.py | 12 + .../backend/blocks/ai_image_customizer.py | 24 +- .../blocks/ai_image_generator_block.py | 26 +- .../blocks/ai_shortform_video_block.py | 80 ++- .../backend/blocks/bannerbear/text_overlay.py | 30 +- .../backend/backend/blocks/basic.py | 27 +- .../backend/blocks/discord/bot_blocks.py | 15 +- .../backend/blocks/fal/ai_video_generator.py | 26 +- .../backend/backend/blocks/flux_kontext.py | 27 +- .../backend/backend/blocks/google/gmail.py | 78 +-- .../backend/backend/blocks/http.py | 20 +- autogpt_platform/backend/backend/blocks/io.py | 14 +- .../backend/backend/blocks/media.py | 71 +- .../backend/backend/blocks/screenshotone.py | 15 +- .../backend/backend/blocks/spreadsheet.py | 13 +- .../backend/backend/blocks/talking_head.py | 24 +- .../test/test_blocks_dos_vulnerability.py | 15 +- .../backend/backend/blocks/test/test_http.py | 33 +- .../backend/backend/blocks/text.py | 16 +- .../backend/backend/data/execution.py | 17 + .../backend/backend/data/workspace.py | 276 ++++++++ .../backend/backend/executor/manager.py | 7 + .../backend/backend/executor/utils.py | 8 + .../backend/backend/executor/utils_test.py | 5 + .../backend/backend/util/cloud_storage.py | 65 +- autogpt_platform/backend/backend/util/file.py | 201 ++++-- .../backend/backend/util/file_test.py | 37 +- .../backend/backend/util/gcs_utils.py | 108 +++ .../backend/backend/util/settings.py | 13 + autogpt_platform/backend/backend/util/test.py | 29 +- .../backend/backend/util/workspace.py | 419 ++++++++++++ .../backend/backend/util/workspace_storage.py | 398 +++++++++++ .../migration.sql | 52 ++ .../migration.sql | 16 + autogpt_platform/backend/schema.prisma | 48 ++ .../frontend/src/app/api/openapi.json | 34 + .../src/app/api/proxy/[...path]/route.ts | 69 ++ .../MarkdownContent/MarkdownContent.tsx | 81 +++ .../components/ToolResponseMessage/helpers.ts | 103 ++- docs/integrations/README.md | 2 +- docs/integrations/block-integrations/basic.md | 8 +- .../block-integrations/multimedia.md | 6 +- docs/platform/block-sdk-guide.md | 44 ++ docs/platform/new_blocks.md | 65 ++ 52 files changed, 3272 insertions(+), 333 deletions(-) create mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/IDEAS.md create mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/workspace_files.py create mode 100644 autogpt_platform/backend/backend/api/features/workspace/__init__.py create mode 100644 autogpt_platform/backend/backend/api/features/workspace/routes.py create mode 100644 autogpt_platform/backend/backend/data/workspace.py create mode 100644 autogpt_platform/backend/backend/util/gcs_utils.py create mode 100644 autogpt_platform/backend/backend/util/workspace.py create mode 100644 autogpt_platform/backend/backend/util/workspace_storage.py create mode 100644 autogpt_platform/backend/migrations/20260127230419_add_user_workspace/migration.sql create mode 100644 autogpt_platform/backend/migrations/20260129011611_remove_workspace_file_source/migration.sql diff --git a/autogpt_platform/CLAUDE.md b/autogpt_platform/CLAUDE.md index 2c76e7db80..9690178587 100644 --- a/autogpt_platform/CLAUDE.md +++ b/autogpt_platform/CLAUDE.md @@ -194,6 +194,50 @@ ex: do the inputs and outputs tie well together? If you get any pushback or hit complex block conditions check the new_blocks guide in the docs. +**Handling files in blocks with `store_media_file()`:** + +When blocks need to work with files (images, videos, documents), use `store_media_file()` from `backend.util.file`. The `return_format` parameter determines what you get back: + +| Format | Use When | Returns | +|--------|----------|---------| +| `"for_local_processing"` | Processing with local tools (ffmpeg, MoviePy, PIL) | Local file path (e.g., `"image.png"`) | +| `"for_external_api"` | Sending content to external APIs (Replicate, OpenAI) | Data URI (e.g., `"data:image/png;base64,..."`) | +| `"for_block_output"` | Returning output from your block | Smart: `workspace://` in CoPilot, data URI in graphs | + +**Examples:** +```python +# INPUT: Need to process file locally with ffmpeg +local_path = await store_media_file( + file=input_data.video, + execution_context=execution_context, + return_format="for_local_processing", +) +# local_path = "video.mp4" - use with Path/ffmpeg/etc + +# INPUT: Need to send to external API like Replicate +image_b64 = await store_media_file( + file=input_data.image, + execution_context=execution_context, + return_format="for_external_api", +) +# image_b64 = "data:image/png;base64,iVBORw0..." - send to API + +# OUTPUT: Returning result from block +result_url = await store_media_file( + file=generated_image_url, + execution_context=execution_context, + return_format="for_block_output", +) +yield "image_url", result_url +# In CoPilot: result_url = "workspace://abc123" +# In graphs: result_url = "data:image/png;base64,..." +``` + +**Key points:** +- `for_block_output` is the ONLY format that auto-adapts to execution context +- Always use `for_block_output` for block outputs unless you have a specific reason not to +- Never hardcode workspace checks - let `for_block_output` handle it + **Modifying the API:** 1. Update route in `/backend/backend/server/routers/` diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/IDEAS.md b/autogpt_platform/backend/backend/api/features/chat/tools/IDEAS.md new file mode 100644 index 0000000000..656aac61c4 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/chat/tools/IDEAS.md @@ -0,0 +1,79 @@ +# CoPilot Tools - Future Ideas + +## Multimodal Image Support for CoPilot + +**Problem:** CoPilot uses a vision-capable model but can't "see" workspace images. When a block generates an image and returns `workspace://abc123`, CoPilot can't evaluate it (e.g., checking blog thumbnail quality). + +**Backend Solution:** +When preparing messages for the LLM, detect `workspace://` image references and convert them to proper image content blocks: + +```python +# Before sending to LLM, scan for workspace image references +# and inject them as image content parts + +# Example message transformation: +# FROM: {"role": "assistant", "content": "Generated image: workspace://abc123"} +# TO: {"role": "assistant", "content": [ +# {"type": "text", "text": "Generated image: workspace://abc123"}, +# {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} +# ]} +``` + +**Where to implement:** +- In the chat stream handler before calling the LLM +- Or in a message preprocessing step +- Need to fetch image from workspace, convert to base64, add as image content + +**Considerations:** +- Only do this for image MIME types (image/png, image/jpeg, etc.) +- May want a size limit (don't pass 10MB images) +- Track which images were "shown" to the AI for frontend indicator +- Cost implications - vision API calls are more expensive + +**Frontend Solution:** +Show visual indicator on workspace files in chat: +- If AI saw the image: normal display +- If AI didn't see it: overlay icon saying "AI can't see this image" + +Requires response metadata indicating which `workspace://` refs were passed to the model. + +--- + +## Output Post-Processing Layer for run_block + +**Problem:** Many blocks produce large outputs that: +- Consume massive context (100KB base64 image = ~133KB tokens) +- Can't fit in conversation +- Break things and cause high LLM costs + +**Proposed Solution:** Instead of modifying individual blocks or `store_media_file()`, implement a centralized output processor in `run_block.py` that handles outputs before they're returned to CoPilot. + +**Benefits:** +1. **Centralized** - one place to handle all output processing +2. **Future-proof** - new blocks automatically get output processing +3. **Keeps blocks pure** - they don't need to know about context constraints +4. **Handles all large outputs** - not just images + +**Processing Rules:** +- Detect base64 data URIs → save to workspace, return `workspace://` reference +- Truncate very long strings (>N chars) with truncation note +- Summarize large arrays/lists (e.g., "Array with 1000 items, first 5: [...]") +- Handle nested large outputs in dicts recursively +- Cap total output size + +**Implementation Location:** `run_block.py` after block execution, before returning `BlockOutputResponse` + +**Example:** +```python +def _process_outputs_for_context( + outputs: dict[str, list[Any]], + workspace_manager: WorkspaceManager, + max_string_length: int = 10000, + max_array_preview: int = 5, +) -> dict[str, list[Any]]: + """Process block outputs to prevent context bloat.""" + processed = {} + for name, values in outputs.items(): + processed[name] = [_process_value(v, workspace_manager) for v in values] + return processed +``` diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py b/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py index beeb128ae9..d078860c3a 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/__init__.py @@ -18,6 +18,12 @@ from .get_doc_page import GetDocPageTool from .run_agent import RunAgentTool from .run_block import RunBlockTool from .search_docs import SearchDocsTool +from .workspace_files import ( + DeleteWorkspaceFileTool, + ListWorkspaceFilesTool, + ReadWorkspaceFileTool, + WriteWorkspaceFileTool, +) if TYPE_CHECKING: from backend.api.features.chat.response_model import StreamToolOutputAvailable @@ -37,6 +43,11 @@ TOOL_REGISTRY: dict[str, BaseTool] = { "view_agent_output": AgentOutputTool(), "search_docs": SearchDocsTool(), "get_doc_page": GetDocPageTool(), + # Workspace tools for CoPilot file operations + "list_workspace_files": ListWorkspaceFilesTool(), + "read_workspace_file": ReadWorkspaceFileTool(), + "write_workspace_file": WriteWorkspaceFileTool(), + "delete_workspace_file": DeleteWorkspaceFileTool(), } # Export individual tool instances for backwards compatibility diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/models.py b/autogpt_platform/backend/backend/api/features/chat/tools/models.py index 8552681d03..49b233784e 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/models.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/models.py @@ -28,6 +28,12 @@ class ResponseType(str, Enum): BLOCK_OUTPUT = "block_output" DOC_SEARCH_RESULTS = "doc_search_results" DOC_PAGE = "doc_page" + # Workspace response types + WORKSPACE_FILE_LIST = "workspace_file_list" + WORKSPACE_FILE_CONTENT = "workspace_file_content" + WORKSPACE_FILE_METADATA = "workspace_file_metadata" + WORKSPACE_FILE_WRITTEN = "workspace_file_written" + WORKSPACE_FILE_DELETED = "workspace_file_deleted" # Long-running operation types OPERATION_STARTED = "operation_started" OPERATION_PENDING = "operation_pending" diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py b/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py index 3f57236564..a59082b399 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/run_block.py @@ -1,6 +1,7 @@ """Tool for executing blocks directly.""" import logging +import uuid from collections import defaultdict from typing import Any @@ -8,6 +9,7 @@ from backend.api.features.chat.model import ChatSession from backend.data.block import get_block from backend.data.execution import ExecutionContext from backend.data.model import CredentialsMetaInput +from backend.data.workspace import get_or_create_workspace from backend.integrations.creds_manager import IntegrationCredentialsManager from backend.util.exceptions import BlockError @@ -223,11 +225,48 @@ class RunBlockTool(BaseTool): ) try: - # Fetch actual credentials and prepare kwargs for block execution - # Create execution context with defaults (blocks may require it) + # Get or create user's workspace for CoPilot file operations + workspace = await get_or_create_workspace(user_id) + + # Generate synthetic IDs for CoPilot context + # Each chat session is treated as its own agent with one continuous run + # This means: + # - graph_id (agent) = session (memories scoped to session when limit_to_agent=True) + # - graph_exec_id (run) = session (memories scoped to session when limit_to_run=True) + # - node_exec_id = unique per block execution + synthetic_graph_id = f"copilot-session-{session.session_id}" + synthetic_graph_exec_id = f"copilot-session-{session.session_id}" + synthetic_node_id = f"copilot-node-{block_id}" + synthetic_node_exec_id = ( + f"copilot-{session.session_id}-{uuid.uuid4().hex[:8]}" + ) + + # Create unified execution context with all required fields + execution_context = ExecutionContext( + # Execution identity + user_id=user_id, + graph_id=synthetic_graph_id, + graph_exec_id=synthetic_graph_exec_id, + graph_version=1, # Versions are 1-indexed + node_id=synthetic_node_id, + node_exec_id=synthetic_node_exec_id, + # Workspace with session scoping + workspace_id=workspace.id, + session_id=session.session_id, + ) + + # Prepare kwargs for block execution + # Keep individual kwargs for backwards compatibility with existing blocks exec_kwargs: dict[str, Any] = { "user_id": user_id, - "execution_context": ExecutionContext(), + "execution_context": execution_context, + # Legacy: individual kwargs for blocks not yet using execution_context + "workspace_id": workspace.id, + "graph_exec_id": synthetic_graph_exec_id, + "node_exec_id": synthetic_node_exec_id, + "node_id": synthetic_node_id, + "graph_version": 1, # Versions are 1-indexed + "graph_id": synthetic_graph_id, } for field_name, cred_meta in matched_credentials.items(): diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/workspace_files.py b/autogpt_platform/backend/backend/api/features/chat/tools/workspace_files.py new file mode 100644 index 0000000000..03532c8fee --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/chat/tools/workspace_files.py @@ -0,0 +1,620 @@ +"""CoPilot tools for workspace file operations.""" + +import base64 +import logging +from typing import Any, Optional + +from pydantic import BaseModel + +from backend.api.features.chat.model import ChatSession +from backend.data.workspace import get_or_create_workspace +from backend.util.settings import Config +from backend.util.virus_scanner import scan_content_safe +from backend.util.workspace import WorkspaceManager + +from .base import BaseTool +from .models import ErrorResponse, ResponseType, ToolResponseBase + +logger = logging.getLogger(__name__) + + +class WorkspaceFileInfoData(BaseModel): + """Data model for workspace file information (not a response itself).""" + + file_id: str + name: str + path: str + mime_type: str + size_bytes: int + + +class WorkspaceFileListResponse(ToolResponseBase): + """Response containing list of workspace files.""" + + type: ResponseType = ResponseType.WORKSPACE_FILE_LIST + files: list[WorkspaceFileInfoData] + total_count: int + + +class WorkspaceFileContentResponse(ToolResponseBase): + """Response containing workspace file content (legacy, for small text files).""" + + type: ResponseType = ResponseType.WORKSPACE_FILE_CONTENT + file_id: str + name: str + path: str + mime_type: str + content_base64: str + + +class WorkspaceFileMetadataResponse(ToolResponseBase): + """Response containing workspace file metadata and download URL (prevents context bloat).""" + + type: ResponseType = ResponseType.WORKSPACE_FILE_METADATA + file_id: str + name: str + path: str + mime_type: str + size_bytes: int + download_url: str + preview: str | None = None # First 500 chars for text files + + +class WorkspaceWriteResponse(ToolResponseBase): + """Response after writing a file to workspace.""" + + type: ResponseType = ResponseType.WORKSPACE_FILE_WRITTEN + file_id: str + name: str + path: str + size_bytes: int + + +class WorkspaceDeleteResponse(ToolResponseBase): + """Response after deleting a file from workspace.""" + + type: ResponseType = ResponseType.WORKSPACE_FILE_DELETED + file_id: str + success: bool + + +class ListWorkspaceFilesTool(BaseTool): + """Tool for listing files in user's workspace.""" + + @property + def name(self) -> str: + return "list_workspace_files" + + @property + def description(self) -> str: + return ( + "List files in the user's workspace. " + "Returns file names, paths, sizes, and metadata. " + "Optionally filter by path prefix." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path_prefix": { + "type": "string", + "description": ( + "Optional path prefix to filter files " + "(e.g., '/documents/' to list only files in documents folder). " + "By default, only files from the current session are listed." + ), + }, + "limit": { + "type": "integer", + "description": "Maximum number of files to return (default 50, max 100)", + "minimum": 1, + "maximum": 100, + }, + "include_all_sessions": { + "type": "boolean", + "description": ( + "If true, list files from all sessions. " + "Default is false (only current session's files)." + ), + }, + }, + "required": [], + } + + @property + def requires_auth(self) -> bool: + return True + + async def _execute( + self, + user_id: str | None, + session: ChatSession, + **kwargs, + ) -> ToolResponseBase: + session_id = session.session_id + + if not user_id: + return ErrorResponse( + message="Authentication required", + session_id=session_id, + ) + + path_prefix: Optional[str] = kwargs.get("path_prefix") + limit = min(kwargs.get("limit", 50), 100) + include_all_sessions: bool = kwargs.get("include_all_sessions", False) + + try: + workspace = await get_or_create_workspace(user_id) + # Pass session_id for session-scoped file access + manager = WorkspaceManager(user_id, workspace.id, session_id) + + files = await manager.list_files( + path=path_prefix, + limit=limit, + include_all_sessions=include_all_sessions, + ) + total = await manager.get_file_count( + path=path_prefix, + include_all_sessions=include_all_sessions, + ) + + file_infos = [ + WorkspaceFileInfoData( + file_id=f.id, + name=f.name, + path=f.path, + mime_type=f.mimeType, + size_bytes=f.sizeBytes, + ) + for f in files + ] + + scope_msg = "all sessions" if include_all_sessions else "current session" + return WorkspaceFileListResponse( + files=file_infos, + total_count=total, + message=f"Found {len(files)} files in workspace ({scope_msg})", + session_id=session_id, + ) + + except Exception as e: + logger.error(f"Error listing workspace files: {e}", exc_info=True) + return ErrorResponse( + message=f"Failed to list workspace files: {str(e)}", + error=str(e), + session_id=session_id, + ) + + +class ReadWorkspaceFileTool(BaseTool): + """Tool for reading file content from workspace.""" + + # Size threshold for returning full content vs metadata+URL + # Files larger than this return metadata with download URL to prevent context bloat + MAX_INLINE_SIZE_BYTES = 32 * 1024 # 32KB + # Preview size for text files + PREVIEW_SIZE = 500 + + @property + def name(self) -> str: + return "read_workspace_file" + + @property + def description(self) -> str: + return ( + "Read a file from the user's workspace. " + "Specify either file_id or path to identify the file. " + "For small text files, returns content directly. " + "For large or binary files, returns metadata and a download URL. " + "Paths are scoped to the current session by default. " + "Use /sessions//... for cross-session access." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "The file's unique ID (from list_workspace_files)", + }, + "path": { + "type": "string", + "description": ( + "The virtual file path (e.g., '/documents/report.pdf'). " + "Scoped to current session by default." + ), + }, + "force_download_url": { + "type": "boolean", + "description": ( + "If true, always return metadata+URL instead of inline content. " + "Default is false (auto-selects based on file size/type)." + ), + }, + }, + "required": [], # At least one must be provided + } + + @property + def requires_auth(self) -> bool: + return True + + def _is_text_mime_type(self, mime_type: str) -> bool: + """Check if the MIME type is a text-based type.""" + text_types = [ + "text/", + "application/json", + "application/xml", + "application/javascript", + "application/x-python", + "application/x-sh", + ] + return any(mime_type.startswith(t) for t in text_types) + + async def _execute( + self, + user_id: str | None, + session: ChatSession, + **kwargs, + ) -> ToolResponseBase: + session_id = session.session_id + + if not user_id: + return ErrorResponse( + message="Authentication required", + session_id=session_id, + ) + + file_id: Optional[str] = kwargs.get("file_id") + path: Optional[str] = kwargs.get("path") + force_download_url: bool = kwargs.get("force_download_url", False) + + if not file_id and not path: + return ErrorResponse( + message="Please provide either file_id or path", + session_id=session_id, + ) + + try: + workspace = await get_or_create_workspace(user_id) + # Pass session_id for session-scoped file access + manager = WorkspaceManager(user_id, workspace.id, session_id) + + # Get file info + if file_id: + file_info = await manager.get_file_info(file_id) + if file_info is None: + return ErrorResponse( + message=f"File not found: {file_id}", + session_id=session_id, + ) + target_file_id = file_id + else: + # path is guaranteed to be non-None here due to the check above + assert path is not None + file_info = await manager.get_file_info_by_path(path) + if file_info is None: + return ErrorResponse( + message=f"File not found at path: {path}", + session_id=session_id, + ) + target_file_id = file_info.id + + # Decide whether to return inline content or metadata+URL + is_small_file = file_info.sizeBytes <= self.MAX_INLINE_SIZE_BYTES + is_text_file = self._is_text_mime_type(file_info.mimeType) + + # Return inline content for small text files (unless force_download_url) + if is_small_file and is_text_file and not force_download_url: + content = await manager.read_file_by_id(target_file_id) + content_b64 = base64.b64encode(content).decode("utf-8") + + return WorkspaceFileContentResponse( + file_id=file_info.id, + name=file_info.name, + path=file_info.path, + mime_type=file_info.mimeType, + content_base64=content_b64, + message=f"Successfully read file: {file_info.name}", + session_id=session_id, + ) + + # Return metadata + workspace:// reference for large or binary files + # This prevents context bloat (100KB file = ~133KB as base64) + # Use workspace:// format so frontend urlTransform can add proxy prefix + download_url = f"workspace://{target_file_id}" + + # Generate preview for text files + preview: str | None = None + if is_text_file: + try: + content = await manager.read_file_by_id(target_file_id) + preview_text = content[: self.PREVIEW_SIZE].decode( + "utf-8", errors="replace" + ) + if len(content) > self.PREVIEW_SIZE: + preview_text += "..." + preview = preview_text + except Exception: + pass # Preview is optional + + return WorkspaceFileMetadataResponse( + file_id=file_info.id, + name=file_info.name, + path=file_info.path, + mime_type=file_info.mimeType, + size_bytes=file_info.sizeBytes, + download_url=download_url, + preview=preview, + message=f"File: {file_info.name} ({file_info.sizeBytes} bytes). Use download_url to retrieve content.", + session_id=session_id, + ) + + except FileNotFoundError as e: + return ErrorResponse( + message=str(e), + session_id=session_id, + ) + except Exception as e: + logger.error(f"Error reading workspace file: {e}", exc_info=True) + return ErrorResponse( + message=f"Failed to read workspace file: {str(e)}", + error=str(e), + session_id=session_id, + ) + + +class WriteWorkspaceFileTool(BaseTool): + """Tool for writing files to workspace.""" + + @property + def name(self) -> str: + return "write_workspace_file" + + @property + def description(self) -> str: + return ( + "Write or create a file in the user's workspace. " + "Provide the content as a base64-encoded string. " + f"Maximum file size is {Config().max_file_size_mb}MB. " + "Files are saved to the current session's folder by default. " + "Use /sessions//... for cross-session access." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Name for the file (e.g., 'report.pdf')", + }, + "content_base64": { + "type": "string", + "description": "Base64-encoded file content", + }, + "path": { + "type": "string", + "description": ( + "Optional virtual path where to save the file " + "(e.g., '/documents/report.pdf'). " + "Defaults to '/{filename}'. Scoped to current session." + ), + }, + "mime_type": { + "type": "string", + "description": ( + "Optional MIME type of the file. " + "Auto-detected from filename if not provided." + ), + }, + "overwrite": { + "type": "boolean", + "description": "Whether to overwrite if file exists at path (default: false)", + }, + }, + "required": ["filename", "content_base64"], + } + + @property + def requires_auth(self) -> bool: + return True + + async def _execute( + self, + user_id: str | None, + session: ChatSession, + **kwargs, + ) -> ToolResponseBase: + session_id = session.session_id + + if not user_id: + return ErrorResponse( + message="Authentication required", + session_id=session_id, + ) + + filename: str = kwargs.get("filename", "") + content_b64: str = kwargs.get("content_base64", "") + path: Optional[str] = kwargs.get("path") + mime_type: Optional[str] = kwargs.get("mime_type") + overwrite: bool = kwargs.get("overwrite", False) + + if not filename: + return ErrorResponse( + message="Please provide a filename", + session_id=session_id, + ) + + if not content_b64: + return ErrorResponse( + message="Please provide content_base64", + session_id=session_id, + ) + + # Decode content + try: + content = base64.b64decode(content_b64) + except Exception: + return ErrorResponse( + message="Invalid base64-encoded content", + session_id=session_id, + ) + + # Check size + max_file_size = Config().max_file_size_mb * 1024 * 1024 + if len(content) > max_file_size: + return ErrorResponse( + message=f"File too large. Maximum size is {Config().max_file_size_mb}MB", + session_id=session_id, + ) + + try: + # Virus scan + await scan_content_safe(content, filename=filename) + + workspace = await get_or_create_workspace(user_id) + # Pass session_id for session-scoped file access + manager = WorkspaceManager(user_id, workspace.id, session_id) + + file_record = await manager.write_file( + content=content, + filename=filename, + path=path, + mime_type=mime_type, + overwrite=overwrite, + ) + + return WorkspaceWriteResponse( + file_id=file_record.id, + name=file_record.name, + path=file_record.path, + size_bytes=file_record.sizeBytes, + message=f"Successfully wrote file: {file_record.name}", + session_id=session_id, + ) + + except ValueError as e: + return ErrorResponse( + message=str(e), + session_id=session_id, + ) + except Exception as e: + logger.error(f"Error writing workspace file: {e}", exc_info=True) + return ErrorResponse( + message=f"Failed to write workspace file: {str(e)}", + error=str(e), + session_id=session_id, + ) + + +class DeleteWorkspaceFileTool(BaseTool): + """Tool for deleting files from workspace.""" + + @property + def name(self) -> str: + return "delete_workspace_file" + + @property + def description(self) -> str: + return ( + "Delete a file from the user's workspace. " + "Specify either file_id or path to identify the file. " + "Paths are scoped to the current session by default. " + "Use /sessions//... for cross-session access." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "file_id": { + "type": "string", + "description": "The file's unique ID (from list_workspace_files)", + }, + "path": { + "type": "string", + "description": ( + "The virtual file path (e.g., '/documents/report.pdf'). " + "Scoped to current session by default." + ), + }, + }, + "required": [], # At least one must be provided + } + + @property + def requires_auth(self) -> bool: + return True + + async def _execute( + self, + user_id: str | None, + session: ChatSession, + **kwargs, + ) -> ToolResponseBase: + session_id = session.session_id + + if not user_id: + return ErrorResponse( + message="Authentication required", + session_id=session_id, + ) + + file_id: Optional[str] = kwargs.get("file_id") + path: Optional[str] = kwargs.get("path") + + if not file_id and not path: + return ErrorResponse( + message="Please provide either file_id or path", + session_id=session_id, + ) + + try: + workspace = await get_or_create_workspace(user_id) + # Pass session_id for session-scoped file access + manager = WorkspaceManager(user_id, workspace.id, session_id) + + # Determine the file_id to delete + target_file_id: str + if file_id: + target_file_id = file_id + else: + # path is guaranteed to be non-None here due to the check above + assert path is not None + file_info = await manager.get_file_info_by_path(path) + if file_info is None: + return ErrorResponse( + message=f"File not found at path: {path}", + session_id=session_id, + ) + target_file_id = file_info.id + + success = await manager.delete_file(target_file_id) + + if not success: + return ErrorResponse( + message=f"File not found: {target_file_id}", + session_id=session_id, + ) + + return WorkspaceDeleteResponse( + file_id=target_file_id, + success=True, + message="File deleted successfully", + session_id=session_id, + ) + + except Exception as e: + logger.error(f"Error deleting workspace file: {e}", exc_info=True) + return ErrorResponse( + message=f"Failed to delete workspace file: {str(e)}", + error=str(e), + session_id=session_id, + ) diff --git a/autogpt_platform/backend/backend/api/features/workspace/__init__.py b/autogpt_platform/backend/backend/api/features/workspace/__init__.py new file mode 100644 index 0000000000..688ada9937 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/workspace/__init__.py @@ -0,0 +1 @@ +# Workspace API feature module diff --git a/autogpt_platform/backend/backend/api/features/workspace/routes.py b/autogpt_platform/backend/backend/api/features/workspace/routes.py new file mode 100644 index 0000000000..b6d0c84572 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/workspace/routes.py @@ -0,0 +1,122 @@ +""" +Workspace API routes for managing user file storage. +""" + +import logging +import re +from typing import Annotated +from urllib.parse import quote + +import fastapi +from autogpt_libs.auth.dependencies import get_user_id, requires_user +from fastapi.responses import Response + +from backend.data.workspace import get_workspace, get_workspace_file +from backend.util.workspace_storage import get_workspace_storage + + +def _sanitize_filename_for_header(filename: str) -> str: + """ + Sanitize filename for Content-Disposition header to prevent header injection. + + Removes/replaces characters that could break the header or inject new headers. + Uses RFC5987 encoding for non-ASCII characters. + """ + # Remove CR, LF, and null bytes (header injection prevention) + sanitized = re.sub(r"[\r\n\x00]", "", filename) + # Escape quotes + sanitized = sanitized.replace('"', '\\"') + # For non-ASCII, use RFC5987 filename* parameter + # Check if filename has non-ASCII characters + try: + sanitized.encode("ascii") + return f'attachment; filename="{sanitized}"' + except UnicodeEncodeError: + # Use RFC5987 encoding for UTF-8 filenames + encoded = quote(sanitized, safe="") + return f"attachment; filename*=UTF-8''{encoded}" + + +logger = logging.getLogger(__name__) + +router = fastapi.APIRouter( + dependencies=[fastapi.Security(requires_user)], +) + + +def _create_streaming_response(content: bytes, file) -> Response: + """Create a streaming response for file content.""" + return Response( + content=content, + media_type=file.mimeType, + headers={ + "Content-Disposition": _sanitize_filename_for_header(file.name), + "Content-Length": str(len(content)), + }, + ) + + +async def _create_file_download_response(file) -> Response: + """ + Create a download response for a workspace file. + + Handles both local storage (direct streaming) and GCS (signed URL redirect + with fallback to streaming). + """ + storage = await get_workspace_storage() + + # For local storage, stream the file directly + if file.storagePath.startswith("local://"): + content = await storage.retrieve(file.storagePath) + return _create_streaming_response(content, file) + + # For GCS, try to redirect to signed URL, fall back to streaming + try: + url = await storage.get_download_url(file.storagePath, expires_in=300) + # If we got back an API path (fallback), stream directly instead + if url.startswith("/api/"): + content = await storage.retrieve(file.storagePath) + return _create_streaming_response(content, file) + return fastapi.responses.RedirectResponse(url=url, status_code=302) + except Exception as e: + # Log the signed URL failure with context + logger.error( + f"Failed to get signed URL for file {file.id} " + f"(storagePath={file.storagePath}): {e}", + exc_info=True, + ) + # Fall back to streaming directly from GCS + try: + content = await storage.retrieve(file.storagePath) + return _create_streaming_response(content, file) + except Exception as fallback_error: + logger.error( + f"Fallback streaming also failed for file {file.id} " + f"(storagePath={file.storagePath}): {fallback_error}", + exc_info=True, + ) + raise + + +@router.get( + "/files/{file_id}/download", + summary="Download file by ID", +) +async def download_file( + user_id: Annotated[str, fastapi.Security(get_user_id)], + file_id: str, +) -> Response: + """ + Download a file by its ID. + + Returns the file content directly or redirects to a signed URL for GCS. + """ + workspace = await get_workspace(user_id) + if workspace is None: + raise fastapi.HTTPException(status_code=404, detail="Workspace not found") + + file = await get_workspace_file(file_id, workspace.id) + if file is None: + raise fastapi.HTTPException(status_code=404, detail="File not found") + + return await _create_file_download_response(file) diff --git a/autogpt_platform/backend/backend/api/rest_api.py b/autogpt_platform/backend/backend/api/rest_api.py index e9556e992f..b936312ce1 100644 --- a/autogpt_platform/backend/backend/api/rest_api.py +++ b/autogpt_platform/backend/backend/api/rest_api.py @@ -32,6 +32,7 @@ import backend.api.features.postmark.postmark import backend.api.features.store.model import backend.api.features.store.routes import backend.api.features.v1 +import backend.api.features.workspace.routes as workspace_routes import backend.data.block import backend.data.db import backend.data.graph @@ -52,6 +53,7 @@ from backend.util.exceptions import ( ) from backend.util.feature_flag import initialize_launchdarkly, shutdown_launchdarkly from backend.util.service import UnhealthyServiceError +from backend.util.workspace_storage import shutdown_workspace_storage from .external.fastapi_app import external_api from .features.analytics import router as analytics_router @@ -124,6 +126,11 @@ async def lifespan_context(app: fastapi.FastAPI): except Exception as e: logger.warning(f"Error shutting down cloud storage handler: {e}") + try: + await shutdown_workspace_storage() + except Exception as e: + logger.warning(f"Error shutting down workspace storage: {e}") + await backend.data.db.disconnect() @@ -315,6 +322,11 @@ app.include_router( tags=["v2", "chat"], prefix="/api/chat", ) +app.include_router( + workspace_routes.router, + tags=["workspace"], + prefix="/api/workspace", +) app.include_router( backend.api.features.oauth.router, tags=["oauth"], diff --git a/autogpt_platform/backend/backend/blocks/ai_image_customizer.py b/autogpt_platform/backend/backend/blocks/ai_image_customizer.py index 83178e924d..91be33a60e 100644 --- a/autogpt_platform/backend/backend/blocks/ai_image_customizer.py +++ b/autogpt_platform/backend/backend/blocks/ai_image_customizer.py @@ -13,6 +13,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import ( APIKeyCredentials, CredentialsField, @@ -117,11 +118,13 @@ class AIImageCustomizerBlock(Block): "credentials": TEST_CREDENTIALS_INPUT, }, test_output=[ - ("image_url", "https://replicate.delivery/generated-image.jpg"), + # Output will be a workspace ref or data URI depending on context + ("image_url", lambda x: x.startswith(("workspace://", "data:"))), ], test_mock={ + # Use data URI to avoid HTTP requests during tests "run_model": lambda *args, **kwargs: MediaFileType( - "https://replicate.delivery/generated-image.jpg" + "data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==" ), }, test_credentials=TEST_CREDENTIALS, @@ -132,8 +135,7 @@ class AIImageCustomizerBlock(Block): input_data: Input, *, credentials: APIKeyCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: try: @@ -141,10 +143,9 @@ class AIImageCustomizerBlock(Block): processed_images = await asyncio.gather( *( store_media_file( - graph_exec_id=graph_exec_id, file=img, - user_id=user_id, - return_content=True, + execution_context=execution_context, + return_format="for_external_api", # Get content for Replicate API ) for img in input_data.images ) @@ -158,7 +159,14 @@ class AIImageCustomizerBlock(Block): aspect_ratio=input_data.aspect_ratio.value, output_format=input_data.output_format.value, ) - yield "image_url", result + + # Store the generated image to the user's workspace for persistence + stored_url = await store_media_file( + file=result, + execution_context=execution_context, + return_format="for_block_output", + ) + yield "image_url", stored_url except Exception as e: yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/ai_image_generator_block.py b/autogpt_platform/backend/backend/blocks/ai_image_generator_block.py index 8c7b6e6102..e40731cd97 100644 --- a/autogpt_platform/backend/backend/blocks/ai_image_generator_block.py +++ b/autogpt_platform/backend/backend/blocks/ai_image_generator_block.py @@ -6,6 +6,7 @@ from replicate.client import Client as ReplicateClient from replicate.helpers import FileOutput from backend.data.block import Block, BlockCategory, BlockSchemaInput, BlockSchemaOutput +from backend.data.execution import ExecutionContext from backend.data.model import ( APIKeyCredentials, CredentialsField, @@ -13,6 +14,8 @@ from backend.data.model import ( SchemaField, ) from backend.integrations.providers import ProviderName +from backend.util.file import store_media_file +from backend.util.type import MediaFileType class ImageSize(str, Enum): @@ -165,11 +168,13 @@ class AIImageGeneratorBlock(Block): test_output=[ ( "image_url", - "https://replicate.delivery/generated-image.webp", + # Test output is a data URI since we now store images + lambda x: x.startswith("data:image/"), ), ], test_mock={ - "_run_client": lambda *args, **kwargs: "https://replicate.delivery/generated-image.webp" + # Return a data URI directly so store_media_file doesn't need to download + "_run_client": lambda *args, **kwargs: "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAQAcJYgCdAEO" }, ) @@ -318,11 +323,24 @@ class AIImageGeneratorBlock(Block): style_text = style_map.get(style, "") return f"{style_text} of" if style_text else "" - async def run(self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs): + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, + ): try: url = await self.generate_image(input_data, credentials) if url: - yield "image_url", url + # Store the generated image to the user's workspace/execution folder + stored_url = await store_media_file( + file=MediaFileType(url), + execution_context=execution_context, + return_format="for_block_output", + ) + yield "image_url", stored_url else: yield "error", "Image generation returned an empty result." except Exception as e: diff --git a/autogpt_platform/backend/backend/blocks/ai_shortform_video_block.py b/autogpt_platform/backend/backend/blocks/ai_shortform_video_block.py index a9e96890d3..eb60843185 100644 --- a/autogpt_platform/backend/backend/blocks/ai_shortform_video_block.py +++ b/autogpt_platform/backend/backend/blocks/ai_shortform_video_block.py @@ -13,6 +13,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import ( APIKeyCredentials, CredentialsField, @@ -21,7 +22,9 @@ from backend.data.model import ( ) from backend.integrations.providers import ProviderName from backend.util.exceptions import BlockExecutionError +from backend.util.file import store_media_file from backend.util.request import Requests +from backend.util.type import MediaFileType TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", @@ -271,7 +274,10 @@ class AIShortformVideoCreatorBlock(Block): "voice": Voice.LILY, "video_style": VisualMediaType.STOCK_VIDEOS, }, - test_output=("video_url", "https://example.com/video.mp4"), + test_output=( + "video_url", + lambda x: x.startswith(("workspace://", "data:")), + ), test_mock={ "create_webhook": lambda *args, **kwargs: ( "test_uuid", @@ -280,15 +286,21 @@ class AIShortformVideoCreatorBlock(Block): "create_video": lambda *args, **kwargs: {"pid": "test_pid"}, "check_video_status": lambda *args, **kwargs: { "status": "ready", - "videoUrl": "https://example.com/video.mp4", + "videoUrl": "data:video/mp4;base64,AAAA", }, - "wait_for_video": lambda *args, **kwargs: "https://example.com/video.mp4", + # Use data URI to avoid HTTP requests during tests + "wait_for_video": lambda *args, **kwargs: "data:video/mp4;base64,AAAA", }, test_credentials=TEST_CREDENTIALS, ) async def run( - self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, ) -> BlockOutput: # Create a new Webhook.site URL webhook_token, webhook_url = await self.create_webhook() @@ -340,7 +352,13 @@ class AIShortformVideoCreatorBlock(Block): ) video_url = await self.wait_for_video(credentials.api_key, pid) logger.debug(f"Video ready: {video_url}") - yield "video_url", video_url + # Store the generated video to the user's workspace for persistence + stored_url = await store_media_file( + file=MediaFileType(video_url), + execution_context=execution_context, + return_format="for_block_output", + ) + yield "video_url", stored_url class AIAdMakerVideoCreatorBlock(Block): @@ -447,7 +465,10 @@ class AIAdMakerVideoCreatorBlock(Block): "https://cdn.revid.ai/uploads/1747076315114-image.png", ], }, - test_output=("video_url", "https://example.com/ad.mp4"), + test_output=( + "video_url", + lambda x: x.startswith(("workspace://", "data:")), + ), test_mock={ "create_webhook": lambda *args, **kwargs: ( "test_uuid", @@ -456,14 +477,21 @@ class AIAdMakerVideoCreatorBlock(Block): "create_video": lambda *args, **kwargs: {"pid": "test_pid"}, "check_video_status": lambda *args, **kwargs: { "status": "ready", - "videoUrl": "https://example.com/ad.mp4", + "videoUrl": "data:video/mp4;base64,AAAA", }, - "wait_for_video": lambda *args, **kwargs: "https://example.com/ad.mp4", + "wait_for_video": lambda *args, **kwargs: "data:video/mp4;base64,AAAA", }, test_credentials=TEST_CREDENTIALS, ) - async def run(self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs): + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, + ): webhook_token, webhook_url = await self.create_webhook() payload = { @@ -531,7 +559,13 @@ class AIAdMakerVideoCreatorBlock(Block): raise RuntimeError("Failed to create video: No project ID returned") video_url = await self.wait_for_video(credentials.api_key, pid) - yield "video_url", video_url + # Store the generated video to the user's workspace for persistence + stored_url = await store_media_file( + file=MediaFileType(video_url), + execution_context=execution_context, + return_format="for_block_output", + ) + yield "video_url", stored_url class AIScreenshotToVideoAdBlock(Block): @@ -626,7 +660,10 @@ class AIScreenshotToVideoAdBlock(Block): "script": "Amazing numbers!", "screenshot_url": "https://cdn.revid.ai/uploads/1747080376028-image.png", }, - test_output=("video_url", "https://example.com/screenshot.mp4"), + test_output=( + "video_url", + lambda x: x.startswith(("workspace://", "data:")), + ), test_mock={ "create_webhook": lambda *args, **kwargs: ( "test_uuid", @@ -635,14 +672,21 @@ class AIScreenshotToVideoAdBlock(Block): "create_video": lambda *args, **kwargs: {"pid": "test_pid"}, "check_video_status": lambda *args, **kwargs: { "status": "ready", - "videoUrl": "https://example.com/screenshot.mp4", + "videoUrl": "data:video/mp4;base64,AAAA", }, - "wait_for_video": lambda *args, **kwargs: "https://example.com/screenshot.mp4", + "wait_for_video": lambda *args, **kwargs: "data:video/mp4;base64,AAAA", }, test_credentials=TEST_CREDENTIALS, ) - async def run(self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs): + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, + ): webhook_token, webhook_url = await self.create_webhook() payload = { @@ -710,4 +754,10 @@ class AIScreenshotToVideoAdBlock(Block): raise RuntimeError("Failed to create video: No project ID returned") video_url = await self.wait_for_video(credentials.api_key, pid) - yield "video_url", video_url + # Store the generated video to the user's workspace for persistence + stored_url = await store_media_file( + file=MediaFileType(video_url), + execution_context=execution_context, + return_format="for_block_output", + ) + yield "video_url", stored_url diff --git a/autogpt_platform/backend/backend/blocks/bannerbear/text_overlay.py b/autogpt_platform/backend/backend/blocks/bannerbear/text_overlay.py index 16d46c0d99..62aaf63d88 100644 --- a/autogpt_platform/backend/backend/blocks/bannerbear/text_overlay.py +++ b/autogpt_platform/backend/backend/blocks/bannerbear/text_overlay.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from pydantic import SecretStr +from backend.data.execution import ExecutionContext from backend.sdk import ( APIKeyCredentials, Block, @@ -17,6 +18,8 @@ from backend.sdk import ( Requests, SchemaField, ) +from backend.util.file import store_media_file +from backend.util.type import MediaFileType from ._config import bannerbear @@ -135,15 +138,17 @@ class BannerbearTextOverlayBlock(Block): }, test_output=[ ("success", True), - ("image_url", "https://cdn.bannerbear.com/test-image.jpg"), + # Output will be a workspace ref or data URI depending on context + ("image_url", lambda x: x.startswith(("workspace://", "data:"))), ("uid", "test-uid-123"), ("status", "completed"), ], test_mock={ + # Use data URI to avoid HTTP requests during tests "_make_api_request": lambda *args, **kwargs: { "uid": "test-uid-123", "status": "completed", - "image_url": "https://cdn.bannerbear.com/test-image.jpg", + "image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/9oACAEBAAA/APn+v//Z", } }, test_credentials=TEST_CREDENTIALS, @@ -177,7 +182,12 @@ class BannerbearTextOverlayBlock(Block): raise Exception(error_msg) async def run( - self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, ) -> BlockOutput: # Build the modifications array modifications = [] @@ -234,6 +244,18 @@ class BannerbearTextOverlayBlock(Block): # Synchronous request - image should be ready yield "success", True - yield "image_url", data.get("image_url", "") + + # Store the generated image to workspace for persistence + image_url = data.get("image_url", "") + if image_url: + stored_url = await store_media_file( + file=MediaFileType(image_url), + execution_context=execution_context, + return_format="for_block_output", + ) + yield "image_url", stored_url + else: + yield "image_url", "" + yield "uid", data.get("uid", "") yield "status", data.get("status", "completed") diff --git a/autogpt_platform/backend/backend/blocks/basic.py b/autogpt_platform/backend/backend/blocks/basic.py index a9c77e2b93..95193b3feb 100644 --- a/autogpt_platform/backend/backend/blocks/basic.py +++ b/autogpt_platform/backend/backend/blocks/basic.py @@ -9,6 +9,7 @@ from backend.data.block import ( BlockSchemaOutput, BlockType, ) +from backend.data.execution import ExecutionContext from backend.data.model import SchemaField from backend.util.file import store_media_file from backend.util.type import MediaFileType, convert @@ -17,10 +18,10 @@ from backend.util.type import MediaFileType, convert class FileStoreBlock(Block): class Input(BlockSchemaInput): file_in: MediaFileType = SchemaField( - description="The file to store in the temporary directory, it can be a URL, data URI, or local path." + description="The file to download and store. Can be a URL (https://...), data URI, or local path." ) base_64: bool = SchemaField( - description="Whether produce an output in base64 format (not recommended, you can pass the string path just fine accross blocks).", + description="Whether to produce output in base64 format (not recommended, you can pass the file reference across blocks).", default=False, advanced=True, title="Produce Base64 Output", @@ -28,13 +29,18 @@ class FileStoreBlock(Block): class Output(BlockSchemaOutput): file_out: MediaFileType = SchemaField( - description="The relative path to the stored file in the temporary directory." + description="Reference to the stored file. In CoPilot: workspace:// URI (visible in list_workspace_files). In graphs: data URI for passing to other blocks." ) def __init__(self): super().__init__( id="cbb50872-625b-42f0-8203-a2ae78242d8a", - description="Stores the input file in the temporary directory.", + description=( + "Downloads and stores a file from a URL, data URI, or local path. " + "Use this to fetch images, documents, or other files for processing. " + "In CoPilot: saves to workspace (use list_workspace_files to see it). " + "In graphs: outputs a data URI to pass to other blocks." + ), categories={BlockCategory.BASIC, BlockCategory.MULTIMEDIA}, input_schema=FileStoreBlock.Input, output_schema=FileStoreBlock.Output, @@ -45,15 +51,18 @@ class FileStoreBlock(Block): self, input_data: Input, *, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: + # Determine return format based on user preference + # for_external_api: always returns data URI (base64) - honors "Produce Base64 Output" + # for_block_output: smart format - workspace:// in CoPilot, data URI in graphs + return_format = "for_external_api" if input_data.base_64 else "for_block_output" + yield "file_out", await store_media_file( - graph_exec_id=graph_exec_id, file=input_data.file_in, - user_id=user_id, - return_content=input_data.base_64, + execution_context=execution_context, + return_format=return_format, ) diff --git a/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py b/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py index 5ecd730f47..4438af1955 100644 --- a/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py +++ b/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py @@ -15,6 +15,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import APIKeyCredentials, SchemaField from backend.util.file import store_media_file from backend.util.request import Requests @@ -666,8 +667,7 @@ class SendDiscordFileBlock(Block): file: MediaFileType, filename: str, message_content: str, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, ) -> dict: intents = discord.Intents.default() intents.guilds = True @@ -731,10 +731,9 @@ class SendDiscordFileBlock(Block): # Local file path - read from stored media file # This would be a path from a previous block's output stored_file = await store_media_file( - graph_exec_id=graph_exec_id, file=file, - user_id=user_id, - return_content=True, # Get as data URI + execution_context=execution_context, + return_format="for_external_api", # Get content to send to Discord ) # Now process as data URI header, encoded = stored_file.split(",", 1) @@ -781,8 +780,7 @@ class SendDiscordFileBlock(Block): input_data: Input, *, credentials: APIKeyCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: try: @@ -793,8 +791,7 @@ class SendDiscordFileBlock(Block): file=input_data.file, filename=input_data.filename, message_content=input_data.message_content, - graph_exec_id=graph_exec_id, - user_id=user_id, + execution_context=execution_context, ) yield "status", result.get("status", "Unknown error") diff --git a/autogpt_platform/backend/backend/blocks/fal/ai_video_generator.py b/autogpt_platform/backend/backend/blocks/fal/ai_video_generator.py index 2a71548dcc..c2079ef159 100644 --- a/autogpt_platform/backend/backend/blocks/fal/ai_video_generator.py +++ b/autogpt_platform/backend/backend/blocks/fal/ai_video_generator.py @@ -17,8 +17,11 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import SchemaField +from backend.util.file import store_media_file from backend.util.request import ClientResponseError, Requests +from backend.util.type import MediaFileType logger = logging.getLogger(__name__) @@ -64,9 +67,13 @@ class AIVideoGeneratorBlock(Block): "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, - test_output=[("video_url", "https://fal.media/files/example/video.mp4")], + test_output=[ + # Output will be a workspace ref or data URI depending on context + ("video_url", lambda x: x.startswith(("workspace://", "data:"))), + ], test_mock={ - "generate_video": lambda *args, **kwargs: "https://fal.media/files/example/video.mp4" + # Use data URI to avoid HTTP requests during tests + "generate_video": lambda *args, **kwargs: "data:video/mp4;base64,AAAA" }, ) @@ -208,11 +215,22 @@ class AIVideoGeneratorBlock(Block): raise RuntimeError(f"API request failed: {str(e)}") async def run( - self, input_data: Input, *, credentials: FalCredentials, **kwargs + self, + input_data: Input, + *, + credentials: FalCredentials, + execution_context: ExecutionContext, + **kwargs, ) -> BlockOutput: try: video_url = await self.generate_video(input_data, credentials) - yield "video_url", video_url + # Store the generated video to the user's workspace for persistence + stored_url = await store_media_file( + file=MediaFileType(video_url), + execution_context=execution_context, + return_format="for_block_output", + ) + yield "video_url", stored_url except Exception as e: error_message = str(e) yield "error", error_message diff --git a/autogpt_platform/backend/backend/blocks/flux_kontext.py b/autogpt_platform/backend/backend/blocks/flux_kontext.py index dd8375c4ce..d56baa6d92 100644 --- a/autogpt_platform/backend/backend/blocks/flux_kontext.py +++ b/autogpt_platform/backend/backend/blocks/flux_kontext.py @@ -12,6 +12,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import ( APIKeyCredentials, CredentialsField, @@ -121,10 +122,12 @@ class AIImageEditorBlock(Block): "credentials": TEST_CREDENTIALS_INPUT, }, test_output=[ - ("output_image", "https://replicate.com/output/edited-image.png"), + # Output will be a workspace ref or data URI depending on context + ("output_image", lambda x: x.startswith(("workspace://", "data:"))), ], test_mock={ - "run_model": lambda *args, **kwargs: "https://replicate.com/output/edited-image.png", + # Use data URI to avoid HTTP requests during tests + "run_model": lambda *args, **kwargs: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", }, test_credentials=TEST_CREDENTIALS, ) @@ -134,8 +137,7 @@ class AIImageEditorBlock(Block): input_data: Input, *, credentials: APIKeyCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: result = await self.run_model( @@ -144,20 +146,25 @@ class AIImageEditorBlock(Block): prompt=input_data.prompt, input_image_b64=( await store_media_file( - graph_exec_id=graph_exec_id, file=input_data.input_image, - user_id=user_id, - return_content=True, + execution_context=execution_context, + return_format="for_external_api", # Get content for Replicate API ) if input_data.input_image else None ), aspect_ratio=input_data.aspect_ratio.value, seed=input_data.seed, - user_id=user_id, - graph_exec_id=graph_exec_id, + user_id=execution_context.user_id or "", + graph_exec_id=execution_context.graph_exec_id or "", ) - yield "output_image", result + # Store the generated image to the user's workspace for persistence + stored_url = await store_media_file( + file=result, + execution_context=execution_context, + return_format="for_block_output", + ) + yield "output_image", stored_url async def run_model( self, diff --git a/autogpt_platform/backend/backend/blocks/google/gmail.py b/autogpt_platform/backend/backend/blocks/google/gmail.py index d1b3ecd4bf..2040cabe3f 100644 --- a/autogpt_platform/backend/backend/blocks/google/gmail.py +++ b/autogpt_platform/backend/backend/blocks/google/gmail.py @@ -21,6 +21,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import SchemaField from backend.util.file import MediaFileType, get_exec_file_path, store_media_file from backend.util.settings import Settings @@ -95,8 +96,7 @@ def _make_mime_text( async def create_mime_message( input_data, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, ) -> str: """Create a MIME message with attachments and return base64-encoded raw message.""" @@ -117,12 +117,12 @@ async def create_mime_message( if input_data.attachments: for attach in input_data.attachments: local_path = await store_media_file( - user_id=user_id, - graph_exec_id=graph_exec_id, file=attach, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", ) - abs_path = get_exec_file_path(graph_exec_id, local_path) + assert execution_context.graph_exec_id # Validated by store_media_file + abs_path = get_exec_file_path(execution_context.graph_exec_id, local_path) part = MIMEBase("application", "octet-stream") with open(abs_path, "rb") as f: part.set_payload(f.read()) @@ -582,27 +582,25 @@ class GmailSendBlock(GmailBase): input_data: Input, *, credentials: GoogleCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: service = self._build_service(credentials, **kwargs) result = await self._send_email( service, input_data, - graph_exec_id, - user_id, + execution_context, ) yield "result", result async def _send_email( - self, service, input_data: Input, graph_exec_id: str, user_id: str + self, service, input_data: Input, execution_context: ExecutionContext ) -> dict: if not input_data.to or not input_data.subject or not input_data.body: raise ValueError( "At least one recipient, subject, and body are required for sending an email" ) - raw_message = await create_mime_message(input_data, graph_exec_id, user_id) + raw_message = await create_mime_message(input_data, execution_context) sent_message = await asyncio.to_thread( lambda: service.users() .messages() @@ -692,30 +690,28 @@ class GmailCreateDraftBlock(GmailBase): input_data: Input, *, credentials: GoogleCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: service = self._build_service(credentials, **kwargs) result = await self._create_draft( service, input_data, - graph_exec_id, - user_id, + execution_context, ) yield "result", GmailDraftResult( id=result["id"], message_id=result["message"]["id"], status="draft_created" ) async def _create_draft( - self, service, input_data: Input, graph_exec_id: str, user_id: str + self, service, input_data: Input, execution_context: ExecutionContext ) -> dict: if not input_data.to or not input_data.subject: raise ValueError( "At least one recipient and subject are required for creating a draft" ) - raw_message = await create_mime_message(input_data, graph_exec_id, user_id) + raw_message = await create_mime_message(input_data, execution_context) draft = await asyncio.to_thread( lambda: service.users() .drafts() @@ -1100,7 +1096,7 @@ class GmailGetThreadBlock(GmailBase): async def _build_reply_message( - service, input_data, graph_exec_id: str, user_id: str + service, input_data, execution_context: ExecutionContext ) -> tuple[str, str]: """ Builds a reply MIME message for Gmail threads. @@ -1190,12 +1186,12 @@ async def _build_reply_message( # Handle attachments for attach in input_data.attachments: local_path = await store_media_file( - user_id=user_id, - graph_exec_id=graph_exec_id, file=attach, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", ) - abs_path = get_exec_file_path(graph_exec_id, local_path) + assert execution_context.graph_exec_id # Validated by store_media_file + abs_path = get_exec_file_path(execution_context.graph_exec_id, local_path) part = MIMEBase("application", "octet-stream") with open(abs_path, "rb") as f: part.set_payload(f.read()) @@ -1311,16 +1307,14 @@ class GmailReplyBlock(GmailBase): input_data: Input, *, credentials: GoogleCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: service = self._build_service(credentials, **kwargs) message = await self._reply( service, input_data, - graph_exec_id, - user_id, + execution_context, ) yield "messageId", message["id"] yield "threadId", message.get("threadId", input_data.threadId) @@ -1343,11 +1337,11 @@ class GmailReplyBlock(GmailBase): yield "email", email async def _reply( - self, service, input_data: Input, graph_exec_id: str, user_id: str + self, service, input_data: Input, execution_context: ExecutionContext ) -> dict: # Build the reply message using the shared helper raw, thread_id = await _build_reply_message( - service, input_data, graph_exec_id, user_id + service, input_data, execution_context ) # Send the message @@ -1441,16 +1435,14 @@ class GmailDraftReplyBlock(GmailBase): input_data: Input, *, credentials: GoogleCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: service = self._build_service(credentials, **kwargs) draft = await self._create_draft_reply( service, input_data, - graph_exec_id, - user_id, + execution_context, ) yield "draftId", draft["id"] yield "messageId", draft["message"]["id"] @@ -1458,11 +1450,11 @@ class GmailDraftReplyBlock(GmailBase): yield "status", "draft_created" async def _create_draft_reply( - self, service, input_data: Input, graph_exec_id: str, user_id: str + self, service, input_data: Input, execution_context: ExecutionContext ) -> dict: # Build the reply message using the shared helper raw, thread_id = await _build_reply_message( - service, input_data, graph_exec_id, user_id + service, input_data, execution_context ) # Create draft with proper thread association @@ -1629,23 +1621,21 @@ class GmailForwardBlock(GmailBase): input_data: Input, *, credentials: GoogleCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: service = self._build_service(credentials, **kwargs) result = await self._forward_message( service, input_data, - graph_exec_id, - user_id, + execution_context, ) yield "messageId", result["id"] yield "threadId", result.get("threadId", "") yield "status", "forwarded" async def _forward_message( - self, service, input_data: Input, graph_exec_id: str, user_id: str + self, service, input_data: Input, execution_context: ExecutionContext ) -> dict: if not input_data.to: raise ValueError("At least one recipient is required for forwarding") @@ -1727,12 +1717,12 @@ To: {original_to} # Add any additional attachments for attach in input_data.additionalAttachments: local_path = await store_media_file( - user_id=user_id, - graph_exec_id=graph_exec_id, file=attach, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", ) - abs_path = get_exec_file_path(graph_exec_id, local_path) + assert execution_context.graph_exec_id # Validated by store_media_file + abs_path = get_exec_file_path(execution_context.graph_exec_id, local_path) part = MIMEBase("application", "octet-stream") with open(abs_path, "rb") as f: part.set_payload(f.read()) diff --git a/autogpt_platform/backend/backend/blocks/http.py b/autogpt_platform/backend/backend/blocks/http.py index 9b27a3b129..77e7fe243f 100644 --- a/autogpt_platform/backend/backend/blocks/http.py +++ b/autogpt_platform/backend/backend/blocks/http.py @@ -15,6 +15,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import ( CredentialsField, CredentialsMetaInput, @@ -116,10 +117,9 @@ class SendWebRequestBlock(Block): @staticmethod async def _prepare_files( - graph_exec_id: str, + execution_context: ExecutionContext, files_name: str, files: list[MediaFileType], - user_id: str, ) -> list[tuple[str, tuple[str, BytesIO, str]]]: """ Prepare files for the request by storing them and reading their content. @@ -127,11 +127,16 @@ class SendWebRequestBlock(Block): (files_name, (filename, BytesIO, mime_type)) """ files_payload: list[tuple[str, tuple[str, BytesIO, str]]] = [] + graph_exec_id = execution_context.graph_exec_id + if graph_exec_id is None: + raise ValueError("graph_exec_id is required for file operations") for media in files: # Normalise to a list so we can repeat the same key rel_path = await store_media_file( - graph_exec_id, media, user_id, return_content=False + file=media, + execution_context=execution_context, + return_format="for_local_processing", ) abs_path = get_exec_file_path(graph_exec_id, rel_path) async with aiofiles.open(abs_path, "rb") as f: @@ -143,7 +148,7 @@ class SendWebRequestBlock(Block): return files_payload async def run( - self, input_data: Input, *, graph_exec_id: str, user_id: str, **kwargs + self, input_data: Input, *, execution_context: ExecutionContext, **kwargs ) -> BlockOutput: # ─── Parse/normalise body ──────────────────────────────────── body = input_data.body @@ -174,7 +179,7 @@ class SendWebRequestBlock(Block): files_payload: list[tuple[str, tuple[str, BytesIO, str]]] = [] if use_files: files_payload = await self._prepare_files( - graph_exec_id, input_data.files_name, input_data.files, user_id + execution_context, input_data.files_name, input_data.files ) # Enforce body format rules @@ -238,9 +243,8 @@ class SendAuthenticatedWebRequestBlock(SendWebRequestBlock): self, input_data: Input, *, - graph_exec_id: str, + execution_context: ExecutionContext, credentials: HostScopedCredentials, - user_id: str, **kwargs, ) -> BlockOutput: # Create SendWebRequestBlock.Input from our input (removing credentials field) @@ -271,6 +275,6 @@ class SendAuthenticatedWebRequestBlock(SendWebRequestBlock): # Use parent class run method async for output_name, output_data in super().run( - base_input, graph_exec_id=graph_exec_id, user_id=user_id, **kwargs + base_input, execution_context=execution_context, **kwargs ): yield output_name, output_data diff --git a/autogpt_platform/backend/backend/blocks/io.py b/autogpt_platform/backend/backend/blocks/io.py index 6f8e62e339..a9c3859490 100644 --- a/autogpt_platform/backend/backend/blocks/io.py +++ b/autogpt_platform/backend/backend/blocks/io.py @@ -12,6 +12,7 @@ from backend.data.block import ( BlockSchemaInput, BlockType, ) +from backend.data.execution import ExecutionContext from backend.data.model import SchemaField from backend.util.file import store_media_file from backend.util.mock import MockObject @@ -462,18 +463,21 @@ class AgentFileInputBlock(AgentInputBlock): self, input_data: Input, *, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: if not input_data.value: return + # Determine return format based on user preference + # for_external_api: always returns data URI (base64) - honors "Produce Base64 Output" + # for_block_output: smart format - workspace:// in CoPilot, data URI in graphs + return_format = "for_external_api" if input_data.base_64 else "for_block_output" + yield "result", await store_media_file( - graph_exec_id=graph_exec_id, file=input_data.value, - user_id=user_id, - return_content=input_data.base_64, + execution_context=execution_context, + return_format=return_format, ) diff --git a/autogpt_platform/backend/backend/blocks/media.py b/autogpt_platform/backend/backend/blocks/media.py index c8d4b4768f..a8d145bc64 100644 --- a/autogpt_platform/backend/backend/blocks/media.py +++ b/autogpt_platform/backend/backend/blocks/media.py @@ -1,6 +1,6 @@ import os import tempfile -from typing import Literal, Optional +from typing import Optional from moviepy.audio.io.AudioFileClip import AudioFileClip from moviepy.video.fx.Loop import Loop @@ -13,6 +13,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import SchemaField from backend.util.file import MediaFileType, get_exec_file_path, store_media_file @@ -46,18 +47,19 @@ class MediaDurationBlock(Block): self, input_data: Input, *, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: # 1) Store the input media locally local_media_path = await store_media_file( - graph_exec_id=graph_exec_id, file=input_data.media_in, - user_id=user_id, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", + ) + assert execution_context.graph_exec_id is not None + media_abspath = get_exec_file_path( + execution_context.graph_exec_id, local_media_path ) - media_abspath = get_exec_file_path(graph_exec_id, local_media_path) # 2) Load the clip if input_data.is_video: @@ -88,10 +90,6 @@ class LoopVideoBlock(Block): default=None, ge=1, ) - output_return_type: Literal["file_path", "data_uri"] = SchemaField( - description="How to return the output video. Either a relative path or base64 data URI.", - default="file_path", - ) class Output(BlockSchemaOutput): video_out: str = SchemaField( @@ -111,17 +109,19 @@ class LoopVideoBlock(Block): self, input_data: Input, *, - node_exec_id: str, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: + assert execution_context.graph_exec_id is not None + assert execution_context.node_exec_id is not None + graph_exec_id = execution_context.graph_exec_id + node_exec_id = execution_context.node_exec_id + # 1) Store the input video locally local_video_path = await store_media_file( - graph_exec_id=graph_exec_id, file=input_data.video_in, - user_id=user_id, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", ) input_abspath = get_exec_file_path(graph_exec_id, local_video_path) @@ -149,12 +149,11 @@ class LoopVideoBlock(Block): looped_clip = looped_clip.with_audio(clip.audio) looped_clip.write_videofile(output_abspath, codec="libx264", audio_codec="aac") - # Return as data URI + # Return output - for_block_output returns workspace:// if available, else data URI video_out = await store_media_file( - graph_exec_id=graph_exec_id, file=output_filename, - user_id=user_id, - return_content=input_data.output_return_type == "data_uri", + execution_context=execution_context, + return_format="for_block_output", ) yield "video_out", video_out @@ -177,10 +176,6 @@ class AddAudioToVideoBlock(Block): description="Volume scale for the newly attached audio track (1.0 = original).", default=1.0, ) - output_return_type: Literal["file_path", "data_uri"] = SchemaField( - description="Return the final output as a relative path or base64 data URI.", - default="file_path", - ) class Output(BlockSchemaOutput): video_out: MediaFileType = SchemaField( @@ -200,23 +195,24 @@ class AddAudioToVideoBlock(Block): self, input_data: Input, *, - node_exec_id: str, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: + assert execution_context.graph_exec_id is not None + assert execution_context.node_exec_id is not None + graph_exec_id = execution_context.graph_exec_id + node_exec_id = execution_context.node_exec_id + # 1) Store the inputs locally local_video_path = await store_media_file( - graph_exec_id=graph_exec_id, file=input_data.video_in, - user_id=user_id, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", ) local_audio_path = await store_media_file( - graph_exec_id=graph_exec_id, file=input_data.audio_in, - user_id=user_id, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", ) abs_temp_dir = os.path.join(tempfile.gettempdir(), "exec_file", graph_exec_id) @@ -240,12 +236,11 @@ class AddAudioToVideoBlock(Block): output_abspath = os.path.join(abs_temp_dir, output_filename) final_clip.write_videofile(output_abspath, codec="libx264", audio_codec="aac") - # 5) Return either path or data URI + # 5) Return output - for_block_output returns workspace:// if available, else data URI video_out = await store_media_file( - graph_exec_id=graph_exec_id, file=output_filename, - user_id=user_id, - return_content=input_data.output_return_type == "data_uri", + execution_context=execution_context, + return_format="for_block_output", ) yield "video_out", video_out diff --git a/autogpt_platform/backend/backend/blocks/screenshotone.py b/autogpt_platform/backend/backend/blocks/screenshotone.py index 1f8947376b..ee998f8da2 100644 --- a/autogpt_platform/backend/backend/blocks/screenshotone.py +++ b/autogpt_platform/backend/backend/blocks/screenshotone.py @@ -11,6 +11,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import ( APIKeyCredentials, CredentialsField, @@ -112,8 +113,7 @@ class ScreenshotWebPageBlock(Block): @staticmethod async def take_screenshot( credentials: APIKeyCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, url: str, viewport_width: int, viewport_height: int, @@ -155,12 +155,11 @@ class ScreenshotWebPageBlock(Block): return { "image": await store_media_file( - graph_exec_id=graph_exec_id, file=MediaFileType( f"data:image/{format.value};base64,{b64encode(content).decode('utf-8')}" ), - user_id=user_id, - return_content=True, + execution_context=execution_context, + return_format="for_block_output", ) } @@ -169,15 +168,13 @@ class ScreenshotWebPageBlock(Block): input_data: Input, *, credentials: APIKeyCredentials, - graph_exec_id: str, - user_id: str, + execution_context: ExecutionContext, **kwargs, ) -> BlockOutput: try: screenshot_data = await self.take_screenshot( credentials=credentials, - graph_exec_id=graph_exec_id, - user_id=user_id, + execution_context=execution_context, url=input_data.url, viewport_width=input_data.viewport_width, viewport_height=input_data.viewport_height, diff --git a/autogpt_platform/backend/backend/blocks/spreadsheet.py b/autogpt_platform/backend/backend/blocks/spreadsheet.py index 211aac23f4..a13f9e2f6d 100644 --- a/autogpt_platform/backend/backend/blocks/spreadsheet.py +++ b/autogpt_platform/backend/backend/blocks/spreadsheet.py @@ -7,6 +7,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import ContributorDetails, SchemaField from backend.util.file import get_exec_file_path, store_media_file from backend.util.type import MediaFileType @@ -98,7 +99,7 @@ class ReadSpreadsheetBlock(Block): ) async def run( - self, input_data: Input, *, graph_exec_id: str, user_id: str, **_kwargs + self, input_data: Input, *, execution_context: ExecutionContext, **_kwargs ) -> BlockOutput: import csv from io import StringIO @@ -106,14 +107,16 @@ class ReadSpreadsheetBlock(Block): # Determine data source - prefer file_input if provided, otherwise use contents if input_data.file_input: stored_file_path = await store_media_file( - user_id=user_id, - graph_exec_id=graph_exec_id, file=input_data.file_input, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", ) # Get full file path - file_path = get_exec_file_path(graph_exec_id, stored_file_path) + assert execution_context.graph_exec_id # Validated by store_media_file + file_path = get_exec_file_path( + execution_context.graph_exec_id, stored_file_path + ) if not Path(file_path).exists(): raise ValueError(f"File does not exist: {file_path}") diff --git a/autogpt_platform/backend/backend/blocks/talking_head.py b/autogpt_platform/backend/backend/blocks/talking_head.py index 7a466bec7e..e01e3d4023 100644 --- a/autogpt_platform/backend/backend/blocks/talking_head.py +++ b/autogpt_platform/backend/backend/blocks/talking_head.py @@ -10,6 +10,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import ( APIKeyCredentials, CredentialsField, @@ -17,7 +18,9 @@ from backend.data.model import ( SchemaField, ) from backend.integrations.providers import ProviderName +from backend.util.file import store_media_file from backend.util.request import Requests +from backend.util.type import MediaFileType TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", @@ -102,7 +105,7 @@ class CreateTalkingAvatarVideoBlock(Block): test_output=[ ( "video_url", - "https://d-id.com/api/clips/abcd1234-5678-efgh-ijkl-mnopqrstuvwx/video", + lambda x: x.startswith(("workspace://", "data:")), ), ], test_mock={ @@ -110,9 +113,10 @@ class CreateTalkingAvatarVideoBlock(Block): "id": "abcd1234-5678-efgh-ijkl-mnopqrstuvwx", "status": "created", }, + # Use data URI to avoid HTTP requests during tests "get_clip_status": lambda *args, **kwargs: { "status": "done", - "result_url": "https://d-id.com/api/clips/abcd1234-5678-efgh-ijkl-mnopqrstuvwx/video", + "result_url": "data:video/mp4;base64,AAAA", }, }, test_credentials=TEST_CREDENTIALS, @@ -138,7 +142,12 @@ class CreateTalkingAvatarVideoBlock(Block): return response.json() async def run( - self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + execution_context: ExecutionContext, + **kwargs, ) -> BlockOutput: # Create the clip payload = { @@ -165,7 +174,14 @@ class CreateTalkingAvatarVideoBlock(Block): for _ in range(input_data.max_polling_attempts): status_response = await self.get_clip_status(credentials.api_key, clip_id) if status_response["status"] == "done": - yield "video_url", status_response["result_url"] + # Store the generated video to the user's workspace for persistence + video_url = status_response["result_url"] + stored_url = await store_media_file( + file=MediaFileType(video_url), + execution_context=execution_context, + return_format="for_block_output", + ) + yield "video_url", stored_url return elif status_response["status"] == "error": raise RuntimeError( diff --git a/autogpt_platform/backend/backend/blocks/test/test_blocks_dos_vulnerability.py b/autogpt_platform/backend/backend/blocks/test/test_blocks_dos_vulnerability.py index 389bb5c636..e2e44b194c 100644 --- a/autogpt_platform/backend/backend/blocks/test/test_blocks_dos_vulnerability.py +++ b/autogpt_platform/backend/backend/blocks/test/test_blocks_dos_vulnerability.py @@ -12,6 +12,7 @@ from backend.blocks.iteration import StepThroughItemsBlock from backend.blocks.llm import AITextSummarizerBlock from backend.blocks.text import ExtractTextInformationBlock from backend.blocks.xml_parser import XMLParserBlock +from backend.data.execution import ExecutionContext from backend.util.file import store_media_file from backend.util.type import MediaFileType @@ -233,9 +234,12 @@ class TestStoreMediaFileSecurity: with pytest.raises(ValueError, match="File too large"): await store_media_file( - graph_exec_id="test", file=MediaFileType(large_data_uri), - user_id="test_user", + execution_context=ExecutionContext( + user_id="test_user", + graph_exec_id="test", + ), + return_format="for_local_processing", ) @patch("backend.util.file.Path") @@ -270,9 +274,12 @@ class TestStoreMediaFileSecurity: # Should raise an error when directory size exceeds limit with pytest.raises(ValueError, match="Disk usage limit exceeded"): await store_media_file( - graph_exec_id="test", file=MediaFileType( "data:text/plain;base64,dGVzdA==" ), # Small test file - user_id="test_user", + execution_context=ExecutionContext( + user_id="test_user", + graph_exec_id="test", + ), + return_format="for_local_processing", ) diff --git a/autogpt_platform/backend/backend/blocks/test/test_http.py b/autogpt_platform/backend/backend/blocks/test/test_http.py index bdc30f3ecf..e01b8e2c5b 100644 --- a/autogpt_platform/backend/backend/blocks/test/test_http.py +++ b/autogpt_platform/backend/backend/blocks/test/test_http.py @@ -11,10 +11,22 @@ from backend.blocks.http import ( HttpMethod, SendAuthenticatedWebRequestBlock, ) +from backend.data.execution import ExecutionContext from backend.data.model import HostScopedCredentials from backend.util.request import Response +def make_test_context( + graph_exec_id: str = "test-exec-id", + user_id: str = "test-user-id", +) -> ExecutionContext: + """Helper to create test ExecutionContext.""" + return ExecutionContext( + user_id=user_id, + graph_exec_id=graph_exec_id, + ) + + class TestHttpBlockWithHostScopedCredentials: """Test suite for HTTP block integration with HostScopedCredentials.""" @@ -105,8 +117,7 @@ class TestHttpBlockWithHostScopedCredentials: async for output_name, output_data in http_block.run( input_data, credentials=exact_match_credentials, - graph_exec_id="test-exec-id", - user_id="test-user-id", + execution_context=make_test_context(), ): result.append((output_name, output_data)) @@ -161,8 +172,7 @@ class TestHttpBlockWithHostScopedCredentials: async for output_name, output_data in http_block.run( input_data, credentials=wildcard_credentials, - graph_exec_id="test-exec-id", - user_id="test-user-id", + execution_context=make_test_context(), ): result.append((output_name, output_data)) @@ -208,8 +218,7 @@ class TestHttpBlockWithHostScopedCredentials: async for output_name, output_data in http_block.run( input_data, credentials=non_matching_credentials, - graph_exec_id="test-exec-id", - user_id="test-user-id", + execution_context=make_test_context(), ): result.append((output_name, output_data)) @@ -258,8 +267,7 @@ class TestHttpBlockWithHostScopedCredentials: async for output_name, output_data in http_block.run( input_data, credentials=exact_match_credentials, - graph_exec_id="test-exec-id", - user_id="test-user-id", + execution_context=make_test_context(), ): result.append((output_name, output_data)) @@ -318,8 +326,7 @@ class TestHttpBlockWithHostScopedCredentials: async for output_name, output_data in http_block.run( input_data, credentials=auto_discovered_creds, # Execution manager found these - graph_exec_id="test-exec-id", - user_id="test-user-id", + execution_context=make_test_context(), ): result.append((output_name, output_data)) @@ -382,8 +389,7 @@ class TestHttpBlockWithHostScopedCredentials: async for output_name, output_data in http_block.run( input_data, credentials=multi_header_creds, - graph_exec_id="test-exec-id", - user_id="test-user-id", + execution_context=make_test_context(), ): result.append((output_name, output_data)) @@ -471,8 +477,7 @@ class TestHttpBlockWithHostScopedCredentials: async for output_name, output_data in http_block.run( input_data, credentials=test_creds, - graph_exec_id="test-exec-id", - user_id="test-user-id", + execution_context=make_test_context(), ): result.append((output_name, output_data)) diff --git a/autogpt_platform/backend/backend/blocks/text.py b/autogpt_platform/backend/backend/blocks/text.py index 5e58e27101..359e22a84f 100644 --- a/autogpt_platform/backend/backend/blocks/text.py +++ b/autogpt_platform/backend/backend/blocks/text.py @@ -11,6 +11,7 @@ from backend.data.block import ( BlockSchemaInput, BlockSchemaOutput, ) +from backend.data.execution import ExecutionContext from backend.data.model import SchemaField from backend.util import json, text from backend.util.file import get_exec_file_path, store_media_file @@ -444,18 +445,21 @@ class FileReadBlock(Block): ) async def run( - self, input_data: Input, *, graph_exec_id: str, user_id: str, **_kwargs + self, input_data: Input, *, execution_context: ExecutionContext, **_kwargs ) -> BlockOutput: # Store the media file properly (handles URLs, data URIs, etc.) stored_file_path = await store_media_file( - user_id=user_id, - graph_exec_id=graph_exec_id, file=input_data.file_input, - return_content=False, + execution_context=execution_context, + return_format="for_local_processing", ) - # Get full file path - file_path = get_exec_file_path(graph_exec_id, stored_file_path) + # Get full file path (graph_exec_id validated by store_media_file above) + if not execution_context.graph_exec_id: + raise ValueError("execution_context.graph_exec_id is required") + file_path = get_exec_file_path( + execution_context.graph_exec_id, stored_file_path + ) if not Path(file_path).exists(): raise ValueError(f"File does not exist: {file_path}") diff --git a/autogpt_platform/backend/backend/data/execution.py b/autogpt_platform/backend/backend/data/execution.py index 3c1fd25c51..afb8c70538 100644 --- a/autogpt_platform/backend/backend/data/execution.py +++ b/autogpt_platform/backend/backend/data/execution.py @@ -83,12 +83,29 @@ class ExecutionContext(BaseModel): model_config = {"extra": "ignore"} + # Execution identity + user_id: Optional[str] = None + graph_id: Optional[str] = None + graph_exec_id: Optional[str] = None + graph_version: Optional[int] = None + node_id: Optional[str] = None + node_exec_id: Optional[str] = None + + # Safety settings human_in_the_loop_safe_mode: bool = True sensitive_action_safe_mode: bool = False + + # User settings user_timezone: str = "UTC" + + # Execution hierarchy root_execution_id: Optional[str] = None parent_execution_id: Optional[str] = None + # Workspace + workspace_id: Optional[str] = None + session_id: Optional[str] = None + # -------------------------- Models -------------------------- # diff --git a/autogpt_platform/backend/backend/data/workspace.py b/autogpt_platform/backend/backend/data/workspace.py new file mode 100644 index 0000000000..f3dba0a294 --- /dev/null +++ b/autogpt_platform/backend/backend/data/workspace.py @@ -0,0 +1,276 @@ +""" +Database CRUD operations for User Workspace. + +This module provides functions for managing user workspaces and workspace files. +""" + +import logging +from datetime import datetime, timezone +from typing import Optional + +from prisma.models import UserWorkspace, UserWorkspaceFile +from prisma.types import UserWorkspaceFileWhereInput + +from backend.util.json import SafeJson + +logger = logging.getLogger(__name__) + + +async def get_or_create_workspace(user_id: str) -> UserWorkspace: + """ + Get user's workspace, creating one if it doesn't exist. + + Uses upsert to handle race conditions when multiple concurrent requests + attempt to create a workspace for the same user. + + Args: + user_id: The user's ID + + Returns: + UserWorkspace instance + """ + workspace = await UserWorkspace.prisma().upsert( + where={"userId": user_id}, + data={ + "create": {"userId": user_id}, + "update": {}, # No updates needed if exists + }, + ) + + return workspace + + +async def get_workspace(user_id: str) -> Optional[UserWorkspace]: + """ + Get user's workspace if it exists. + + Args: + user_id: The user's ID + + Returns: + UserWorkspace instance or None + """ + return await UserWorkspace.prisma().find_unique(where={"userId": user_id}) + + +async def create_workspace_file( + workspace_id: str, + file_id: str, + name: str, + path: str, + storage_path: str, + mime_type: str, + size_bytes: int, + checksum: Optional[str] = None, + metadata: Optional[dict] = None, +) -> UserWorkspaceFile: + """ + Create a new workspace file record. + + Args: + workspace_id: The workspace ID + file_id: The file ID (same as used in storage path for consistency) + name: User-visible filename + path: Virtual path (e.g., "/documents/report.pdf") + storage_path: Actual storage path (GCS or local) + mime_type: MIME type of the file + size_bytes: File size in bytes + checksum: Optional SHA256 checksum + metadata: Optional additional metadata + + Returns: + Created UserWorkspaceFile instance + """ + # Normalize path to start with / + if not path.startswith("/"): + path = f"/{path}" + + file = await UserWorkspaceFile.prisma().create( + data={ + "id": file_id, + "workspaceId": workspace_id, + "name": name, + "path": path, + "storagePath": storage_path, + "mimeType": mime_type, + "sizeBytes": size_bytes, + "checksum": checksum, + "metadata": SafeJson(metadata or {}), + } + ) + + logger.info( + f"Created workspace file {file.id} at path {path} " + f"in workspace {workspace_id}" + ) + return file + + +async def get_workspace_file( + file_id: str, + workspace_id: Optional[str] = None, +) -> Optional[UserWorkspaceFile]: + """ + Get a workspace file by ID. + + Args: + file_id: The file ID + workspace_id: Optional workspace ID for validation + + Returns: + UserWorkspaceFile instance or None + """ + where_clause: dict = {"id": file_id, "isDeleted": False} + if workspace_id: + where_clause["workspaceId"] = workspace_id + + return await UserWorkspaceFile.prisma().find_first(where=where_clause) + + +async def get_workspace_file_by_path( + workspace_id: str, + path: str, +) -> Optional[UserWorkspaceFile]: + """ + Get a workspace file by its virtual path. + + Args: + workspace_id: The workspace ID + path: Virtual path + + Returns: + UserWorkspaceFile instance or None + """ + # Normalize path + if not path.startswith("/"): + path = f"/{path}" + + return await UserWorkspaceFile.prisma().find_first( + where={ + "workspaceId": workspace_id, + "path": path, + "isDeleted": False, + } + ) + + +async def list_workspace_files( + workspace_id: str, + path_prefix: Optional[str] = None, + include_deleted: bool = False, + limit: Optional[int] = None, + offset: int = 0, +) -> list[UserWorkspaceFile]: + """ + List files in a workspace. + + Args: + workspace_id: The workspace ID + path_prefix: Optional path prefix to filter (e.g., "/documents/") + include_deleted: Whether to include soft-deleted files + limit: Maximum number of files to return + offset: Number of files to skip + + Returns: + List of UserWorkspaceFile instances + """ + where_clause: UserWorkspaceFileWhereInput = {"workspaceId": workspace_id} + + if not include_deleted: + where_clause["isDeleted"] = False + + if path_prefix: + # Normalize prefix + if not path_prefix.startswith("/"): + path_prefix = f"/{path_prefix}" + where_clause["path"] = {"startswith": path_prefix} + + return await UserWorkspaceFile.prisma().find_many( + where=where_clause, + order={"createdAt": "desc"}, + take=limit, + skip=offset, + ) + + +async def count_workspace_files( + workspace_id: str, + path_prefix: Optional[str] = None, + include_deleted: bool = False, +) -> int: + """ + Count files in a workspace. + + Args: + workspace_id: The workspace ID + path_prefix: Optional path prefix to filter (e.g., "/sessions/abc123/") + include_deleted: Whether to include soft-deleted files + + Returns: + Number of files + """ + where_clause: dict = {"workspaceId": workspace_id} + if not include_deleted: + where_clause["isDeleted"] = False + + if path_prefix: + # Normalize prefix + if not path_prefix.startswith("/"): + path_prefix = f"/{path_prefix}" + where_clause["path"] = {"startswith": path_prefix} + + return await UserWorkspaceFile.prisma().count(where=where_clause) + + +async def soft_delete_workspace_file( + file_id: str, + workspace_id: Optional[str] = None, +) -> Optional[UserWorkspaceFile]: + """ + Soft-delete a workspace file. + + The path is modified to include a deletion timestamp to free up the original + path for new files while preserving the record for potential recovery. + + Args: + file_id: The file ID + workspace_id: Optional workspace ID for validation + + Returns: + Updated UserWorkspaceFile instance or None if not found + """ + # First verify the file exists and belongs to workspace + file = await get_workspace_file(file_id, workspace_id) + if file is None: + return None + + deleted_at = datetime.now(timezone.utc) + # Modify path to free up the unique constraint for new files at original path + # Format: {original_path}__deleted__{timestamp} + deleted_path = f"{file.path}__deleted__{int(deleted_at.timestamp())}" + + updated = await UserWorkspaceFile.prisma().update( + where={"id": file_id}, + data={ + "isDeleted": True, + "deletedAt": deleted_at, + "path": deleted_path, + }, + ) + + logger.info(f"Soft-deleted workspace file {file_id}") + return updated + + +async def get_workspace_total_size(workspace_id: str) -> int: + """ + Get the total size of all files in a workspace. + + Args: + workspace_id: The workspace ID + + Returns: + Total size in bytes + """ + files = await list_workspace_files(workspace_id) + return sum(file.sizeBytes for file in files) diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py index 39d4f984eb..8362dae828 100644 --- a/autogpt_platform/backend/backend/executor/manager.py +++ b/autogpt_platform/backend/backend/executor/manager.py @@ -236,7 +236,14 @@ async def execute_node( input_size = len(input_data_str) log_metadata.debug("Executed node with input", input=input_data_str) + # Create node-specific execution context to avoid race conditions + # (multiple nodes can execute concurrently and would otherwise mutate shared state) + execution_context = execution_context.model_copy( + update={"node_id": node_id, "node_exec_id": node_exec_id} + ) + # Inject extra execution arguments for the blocks via kwargs + # Keep individual kwargs for backwards compatibility with existing blocks extra_exec_kwargs: dict = { "graph_id": graph_id, "graph_version": graph_version, diff --git a/autogpt_platform/backend/backend/executor/utils.py b/autogpt_platform/backend/backend/executor/utils.py index f35bebb125..fa264c30a7 100644 --- a/autogpt_platform/backend/backend/executor/utils.py +++ b/autogpt_platform/backend/backend/executor/utils.py @@ -892,11 +892,19 @@ async def add_graph_execution( settings = await gdb.get_graph_settings(user_id=user_id, graph_id=graph_id) execution_context = ExecutionContext( + # Execution identity + user_id=user_id, + graph_id=graph_id, + graph_exec_id=graph_exec.id, + graph_version=graph_exec.graph_version, + # Safety settings human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode, sensitive_action_safe_mode=settings.sensitive_action_safe_mode, + # User settings user_timezone=( user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC" ), + # Execution hierarchy root_execution_id=graph_exec.id, ) diff --git a/autogpt_platform/backend/backend/executor/utils_test.py b/autogpt_platform/backend/backend/executor/utils_test.py index 4761a18c63..db33249583 100644 --- a/autogpt_platform/backend/backend/executor/utils_test.py +++ b/autogpt_platform/backend/backend/executor/utils_test.py @@ -348,6 +348,7 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture): mock_graph_exec.id = "execution-id-123" mock_graph_exec.node_executions = [] # Add this to avoid AttributeError mock_graph_exec.status = ExecutionStatus.QUEUED # Required for race condition check + mock_graph_exec.graph_version = graph_version mock_graph_exec.to_graph_execution_entry.return_value = mocker.MagicMock() # Mock the queue and event bus @@ -434,6 +435,9 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture): # Create a second mock execution for the sanity check mock_graph_exec_2 = mocker.MagicMock(spec=GraphExecutionWithNodes) mock_graph_exec_2.id = "execution-id-456" + mock_graph_exec_2.node_executions = [] + mock_graph_exec_2.status = ExecutionStatus.QUEUED + mock_graph_exec_2.graph_version = graph_version mock_graph_exec_2.to_graph_execution_entry.return_value = mocker.MagicMock() # Reset mocks and set up for second call @@ -614,6 +618,7 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture): mock_graph_exec.id = "execution-id-123" mock_graph_exec.node_executions = [] mock_graph_exec.status = ExecutionStatus.QUEUED # Required for race condition check + mock_graph_exec.graph_version = graph_version # Track what's passed to to_graph_execution_entry captured_kwargs = {} diff --git a/autogpt_platform/backend/backend/util/cloud_storage.py b/autogpt_platform/backend/backend/util/cloud_storage.py index 93fb9039ec..28423d003d 100644 --- a/autogpt_platform/backend/backend/util/cloud_storage.py +++ b/autogpt_platform/backend/backend/util/cloud_storage.py @@ -13,6 +13,7 @@ import aiohttp from gcloud.aio import storage as async_gcs_storage from google.cloud import storage as gcs_storage +from backend.util.gcs_utils import download_with_fresh_session, generate_signed_url from backend.util.settings import Config logger = logging.getLogger(__name__) @@ -251,7 +252,7 @@ class CloudStorageHandler: f"in_task: {current_task is not None}" ) - # Parse bucket and blob name from path + # Parse bucket and blob name from path (path already has gcs:// prefix removed) parts = path.split("/", 1) if len(parts) != 2: raise ValueError(f"Invalid GCS path: {path}") @@ -261,50 +262,19 @@ class CloudStorageHandler: # Authorization check self._validate_file_access(blob_name, user_id, graph_exec_id) - # Use a fresh client for each download to avoid session issues - # This is less efficient but more reliable with the executor's event loop - logger.info("[CloudStorage] Creating fresh GCS client for download") - - # Create a new session specifically for this download - session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(limit=10, force_close=True) + logger.info( + f"[CloudStorage] About to download from GCS - bucket: {bucket_name}, blob: {blob_name}" ) - async_client = None try: - # Create a new GCS client with the fresh session - async_client = async_gcs_storage.Storage(session=session) - - logger.info( - f"[CloudStorage] About to download from GCS - bucket: {bucket_name}, blob: {blob_name}" - ) - - # Download content using the fresh client - content = await async_client.download(bucket_name, blob_name) + content = await download_with_fresh_session(bucket_name, blob_name) logger.info( f"[CloudStorage] GCS download successful - size: {len(content)} bytes" ) - - # Clean up - await async_client.close() - await session.close() - return content - + except FileNotFoundError: + raise except Exception as e: - # Always try to clean up - if async_client is not None: - try: - await async_client.close() - except Exception as cleanup_error: - logger.warning( - f"[CloudStorage] Error closing GCS client: {cleanup_error}" - ) - try: - await session.close() - except Exception as cleanup_error: - logger.warning(f"[CloudStorage] Error closing session: {cleanup_error}") - # Log the specific error for debugging logger.error( f"[CloudStorage] GCS download failed - error: {str(e)}, " @@ -319,10 +289,6 @@ class CloudStorageHandler: f"current_task: {current_task}, " f"bucket: {bucket_name}, blob: redacted for privacy" ) - - # Convert gcloud-aio exceptions to standard ones - if "404" in str(e) or "Not Found" in str(e): - raise FileNotFoundError(f"File not found: gcs://{path}") raise def _validate_file_access( @@ -445,8 +411,7 @@ class CloudStorageHandler: graph_exec_id: str | None = None, ) -> str: """Generate signed URL for GCS with authorization.""" - - # Parse bucket and blob name from path + # Parse bucket and blob name from path (path already has gcs:// prefix removed) parts = path.split("/", 1) if len(parts) != 2: raise ValueError(f"Invalid GCS path: {path}") @@ -456,21 +421,11 @@ class CloudStorageHandler: # Authorization check self._validate_file_access(blob_name, user_id, graph_exec_id) - # Use sync client for signed URLs since gcloud-aio doesn't support them sync_client = self._get_sync_gcs_client() - bucket = sync_client.bucket(bucket_name) - blob = bucket.blob(blob_name) - - # Generate signed URL asynchronously using sync client - url = await asyncio.to_thread( - blob.generate_signed_url, - version="v4", - expiration=datetime.now(timezone.utc) + timedelta(hours=expiration_hours), - method="GET", + return await generate_signed_url( + sync_client, bucket_name, blob_name, expiration_hours * 3600 ) - return url - async def delete_expired_files(self, provider: str = "gcs") -> int: """ Delete files that have passed their expiration time. diff --git a/autogpt_platform/backend/backend/util/file.py b/autogpt_platform/backend/backend/util/file.py index dc8f86ea41..baa9225629 100644 --- a/autogpt_platform/backend/backend/util/file.py +++ b/autogpt_platform/backend/backend/util/file.py @@ -5,13 +5,26 @@ import shutil import tempfile import uuid from pathlib import Path +from typing import TYPE_CHECKING, Literal from urllib.parse import urlparse from backend.util.cloud_storage import get_cloud_storage_handler from backend.util.request import Requests +from backend.util.settings import Config from backend.util.type import MediaFileType from backend.util.virus_scanner import scan_content_safe +if TYPE_CHECKING: + from backend.data.execution import ExecutionContext + +# Return format options for store_media_file +# - "for_local_processing": Returns local file path - use with ffmpeg, MoviePy, PIL, etc. +# - "for_external_api": Returns data URI (base64) - use when sending content to external APIs +# - "for_block_output": Returns best format for output - workspace:// in CoPilot, data URI in graphs +MediaReturnFormat = Literal[ + "for_local_processing", "for_external_api", "for_block_output" +] + TEMP_DIR = Path(tempfile.gettempdir()).resolve() # Maximum filename length (conservative limit for most filesystems) @@ -67,42 +80,56 @@ def clean_exec_files(graph_exec_id: str, file: str = "") -> None: async def store_media_file( - graph_exec_id: str, file: MediaFileType, - user_id: str, - return_content: bool = False, + execution_context: "ExecutionContext", + *, + return_format: MediaReturnFormat, ) -> MediaFileType: """ - Safely handle 'file' (a data URI, a URL, or a local path relative to {temp}/exec_file/{exec_id}), - placing or verifying it under: + Safely handle 'file' (a data URI, a URL, a workspace:// reference, or a local path + relative to {temp}/exec_file/{exec_id}), placing or verifying it under: {tempdir}/exec_file/{exec_id}/... - If 'return_content=True', return a data URI (data:;base64,). - Otherwise, returns the file media path relative to the exec_id folder. + For each MediaFileType input: + - Data URI: decode and store locally + - URL: download and store locally + - workspace:// reference: read from workspace, store locally + - Local path: verify it exists in exec_file directory - For each MediaFileType type: - - Data URI: - -> decode and store in a new random file in that folder - - URL: - -> download and store in that folder - - Local path: - -> interpret as relative to that folder; verify it exists - (no copying, as it's presumably already there). - We realpath-check so no symlink or '..' can escape the folder. + Return format options: + - "for_local_processing": Returns local file path - use with ffmpeg, MoviePy, PIL, etc. + - "for_external_api": Returns data URI (base64) - use when sending to external APIs + - "for_block_output": Returns best format for output - workspace:// in CoPilot, data URI in graphs - - :param graph_exec_id: The unique ID of the graph execution. - :param file: Data URI, URL, or local (relative) path. - :param return_content: If True, return a data URI of the file content. - If False, return the *relative* path inside the exec_id folder. - :return: The requested result: data URI or relative path of the media. + :param file: Data URI, URL, workspace://, or local (relative) path. + :param execution_context: ExecutionContext with user_id, graph_exec_id, workspace_id. + :param return_format: What to return: "for_local_processing", "for_external_api", or "for_block_output". + :return: The requested result based on return_format. """ + # Extract values from execution_context + graph_exec_id = execution_context.graph_exec_id + user_id = execution_context.user_id + + if not graph_exec_id: + raise ValueError("execution_context.graph_exec_id is required") + if not user_id: + raise ValueError("execution_context.user_id is required") + + # Create workspace_manager if we have workspace_id (with session scoping) + # Import here to avoid circular import (file.py → workspace.py → data → blocks → file.py) + from backend.util.workspace import WorkspaceManager + + workspace_manager: WorkspaceManager | None = None + if execution_context.workspace_id: + workspace_manager = WorkspaceManager( + user_id, execution_context.workspace_id, execution_context.session_id + ) # Build base path base_path = Path(get_exec_file_path(graph_exec_id, "")) base_path.mkdir(parents=True, exist_ok=True) # Security fix: Add disk space limits to prevent DoS - MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB per file + MAX_FILE_SIZE_BYTES = Config().max_file_size_mb * 1024 * 1024 MAX_TOTAL_DISK_USAGE = 1024 * 1024 * 1024 # 1GB total per execution directory # Check total disk usage in base_path @@ -142,9 +169,57 @@ async def store_media_file( """ return str(absolute_path.relative_to(base)) - # Check if this is a cloud storage path + # Get cloud storage handler for checking cloud paths cloud_storage = await get_cloud_storage_handler() - if cloud_storage.is_cloud_path(file): + + # Track if the input came from workspace (don't re-save it) + is_from_workspace = file.startswith("workspace://") + + # Check if this is a workspace file reference + if is_from_workspace: + if workspace_manager is None: + raise ValueError( + "Workspace file reference requires workspace context. " + "This file type is only available in CoPilot sessions." + ) + + # Parse workspace reference + # workspace://abc123 - by file ID + # workspace:///path/to/file.txt - by virtual path + file_ref = file[12:] # Remove "workspace://" + + if file_ref.startswith("/"): + # Path reference + workspace_content = await workspace_manager.read_file(file_ref) + file_info = await workspace_manager.get_file_info_by_path(file_ref) + filename = sanitize_filename( + file_info.name if file_info else f"{uuid.uuid4()}.bin" + ) + else: + # ID reference + workspace_content = await workspace_manager.read_file_by_id(file_ref) + file_info = await workspace_manager.get_file_info(file_ref) + filename = sanitize_filename( + file_info.name if file_info else f"{uuid.uuid4()}.bin" + ) + + try: + target_path = _ensure_inside_base(base_path / filename, base_path) + except OSError as e: + raise ValueError(f"Invalid file path '{filename}': {e}") from e + + # Check file size limit + if len(workspace_content) > MAX_FILE_SIZE_BYTES: + raise ValueError( + f"File too large: {len(workspace_content)} bytes > {MAX_FILE_SIZE_BYTES} bytes" + ) + + # Virus scan the workspace content before writing locally + await scan_content_safe(workspace_content, filename=filename) + target_path.write_bytes(workspace_content) + + # Check if this is a cloud storage path + elif cloud_storage.is_cloud_path(file): # Download from cloud storage and store locally cloud_content = await cloud_storage.retrieve_file( file, user_id=user_id, graph_exec_id=graph_exec_id @@ -159,9 +234,9 @@ async def store_media_file( raise ValueError(f"Invalid file path '{filename}': {e}") from e # Check file size limit - if len(cloud_content) > MAX_FILE_SIZE: + if len(cloud_content) > MAX_FILE_SIZE_BYTES: raise ValueError( - f"File too large: {len(cloud_content)} bytes > {MAX_FILE_SIZE} bytes" + f"File too large: {len(cloud_content)} bytes > {MAX_FILE_SIZE_BYTES} bytes" ) # Virus scan the cloud content before writing locally @@ -189,9 +264,9 @@ async def store_media_file( content = base64.b64decode(b64_content) # Check file size limit - if len(content) > MAX_FILE_SIZE: + if len(content) > MAX_FILE_SIZE_BYTES: raise ValueError( - f"File too large: {len(content)} bytes > {MAX_FILE_SIZE} bytes" + f"File too large: {len(content)} bytes > {MAX_FILE_SIZE_BYTES} bytes" ) # Virus scan the base64 content before writing @@ -199,23 +274,31 @@ async def store_media_file( target_path.write_bytes(content) elif file.startswith(("http://", "https://")): - # URL + # URL - download first to get Content-Type header + resp = await Requests().get(file) + + # Check file size limit + if len(resp.content) > MAX_FILE_SIZE_BYTES: + raise ValueError( + f"File too large: {len(resp.content)} bytes > {MAX_FILE_SIZE_BYTES} bytes" + ) + + # Extract filename from URL path parsed_url = urlparse(file) filename = sanitize_filename(Path(parsed_url.path).name or f"{uuid.uuid4()}") + + # If filename lacks extension, add one from Content-Type header + if "." not in filename: + content_type = resp.headers.get("Content-Type", "").split(";")[0].strip() + if content_type: + ext = _extension_from_mime(content_type) + filename = f"{filename}{ext}" + try: target_path = _ensure_inside_base(base_path / filename, base_path) except OSError as e: raise ValueError(f"Invalid file path '{filename}': {e}") from e - # Download and save - resp = await Requests().get(file) - - # Check file size limit - if len(resp.content) > MAX_FILE_SIZE: - raise ValueError( - f"File too large: {len(resp.content)} bytes > {MAX_FILE_SIZE} bytes" - ) - # Virus scan the downloaded content before writing await scan_content_safe(resp.content, filename=filename) target_path.write_bytes(resp.content) @@ -230,12 +313,44 @@ async def store_media_file( if not target_path.is_file(): raise ValueError(f"Local file does not exist: {target_path}") - # Return result - if return_content: - return MediaFileType(_file_to_data_uri(target_path)) - else: + # Return based on requested format + if return_format == "for_local_processing": + # Use when processing files locally with tools like ffmpeg, MoviePy, PIL + # Returns: relative path in exec_file directory (e.g., "image.png") return MediaFileType(_strip_base_prefix(target_path, base_path)) + elif return_format == "for_external_api": + # Use when sending content to external APIs that need base64 + # Returns: data URI (e.g., "data:image/png;base64,iVBORw0...") + return MediaFileType(_file_to_data_uri(target_path)) + + elif return_format == "for_block_output": + # Use when returning output from a block to user/next block + # Returns: workspace:// ref (CoPilot) or data URI (graph execution) + if workspace_manager is None: + # No workspace available (graph execution without CoPilot) + # Fallback to data URI so the content can still be used/displayed + return MediaFileType(_file_to_data_uri(target_path)) + + # Don't re-save if input was already from workspace + if is_from_workspace: + # Return original workspace reference + return MediaFileType(file) + + # Save new content to workspace + content = target_path.read_bytes() + filename = target_path.name + + file_record = await workspace_manager.write_file( + content=content, + filename=filename, + overwrite=True, + ) + return MediaFileType(f"workspace://{file_record.id}") + + else: + raise ValueError(f"Invalid return_format: {return_format}") + def get_dir_size(path: Path) -> int: """Get total size of directory.""" diff --git a/autogpt_platform/backend/backend/util/file_test.py b/autogpt_platform/backend/backend/util/file_test.py index cd4fc69706..9fe672d155 100644 --- a/autogpt_platform/backend/backend/util/file_test.py +++ b/autogpt_platform/backend/backend/util/file_test.py @@ -7,10 +7,22 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from backend.data.execution import ExecutionContext from backend.util.file import store_media_file from backend.util.type import MediaFileType +def make_test_context( + graph_exec_id: str = "test-exec-123", + user_id: str = "test-user-123", +) -> ExecutionContext: + """Helper to create test ExecutionContext.""" + return ExecutionContext( + user_id=user_id, + graph_exec_id=graph_exec_id, + ) + + class TestFileCloudIntegration: """Test cases for cloud storage integration in file utilities.""" @@ -70,10 +82,9 @@ class TestFileCloudIntegration: mock_path_class.side_effect = path_constructor result = await store_media_file( - graph_exec_id, - MediaFileType(cloud_path), - "test-user-123", - return_content=False, + file=MediaFileType(cloud_path), + execution_context=make_test_context(graph_exec_id=graph_exec_id), + return_format="for_local_processing", ) # Verify cloud storage operations @@ -144,10 +155,9 @@ class TestFileCloudIntegration: mock_path_obj.name = "image.png" with patch("backend.util.file.Path", return_value=mock_path_obj): result = await store_media_file( - graph_exec_id, - MediaFileType(cloud_path), - "test-user-123", - return_content=True, + file=MediaFileType(cloud_path), + execution_context=make_test_context(graph_exec_id=graph_exec_id), + return_format="for_external_api", ) # Verify result is a data URI @@ -198,10 +208,9 @@ class TestFileCloudIntegration: mock_resolved_path.relative_to.return_value = Path("test-uuid-789.txt") await store_media_file( - graph_exec_id, - MediaFileType(data_uri), - "test-user-123", - return_content=False, + file=MediaFileType(data_uri), + execution_context=make_test_context(graph_exec_id=graph_exec_id), + return_format="for_local_processing", ) # Verify cloud handler was checked but not used for retrieval @@ -234,5 +243,7 @@ class TestFileCloudIntegration: FileNotFoundError, match="File not found in cloud storage" ): await store_media_file( - graph_exec_id, MediaFileType(cloud_path), "test-user-123" + file=MediaFileType(cloud_path), + execution_context=make_test_context(graph_exec_id=graph_exec_id), + return_format="for_local_processing", ) diff --git a/autogpt_platform/backend/backend/util/gcs_utils.py b/autogpt_platform/backend/backend/util/gcs_utils.py new file mode 100644 index 0000000000..3f91f21897 --- /dev/null +++ b/autogpt_platform/backend/backend/util/gcs_utils.py @@ -0,0 +1,108 @@ +""" +Shared GCS utilities for workspace and cloud storage backends. + +This module provides common functionality for working with Google Cloud Storage, +including path parsing, client management, and signed URL generation. +""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone + +import aiohttp +from gcloud.aio import storage as async_gcs_storage +from google.cloud import storage as gcs_storage + +logger = logging.getLogger(__name__) + + +def parse_gcs_path(path: str) -> tuple[str, str]: + """ + Parse a GCS path in the format 'gcs://bucket/blob' to (bucket, blob). + + Args: + path: GCS path string (e.g., "gcs://my-bucket/path/to/file") + + Returns: + Tuple of (bucket_name, blob_name) + + Raises: + ValueError: If the path format is invalid + """ + if not path.startswith("gcs://"): + raise ValueError(f"Invalid GCS path: {path}") + + path_without_prefix = path[6:] # Remove "gcs://" + parts = path_without_prefix.split("/", 1) + if len(parts) != 2: + raise ValueError(f"Invalid GCS path format: {path}") + + return parts[0], parts[1] + + +async def download_with_fresh_session(bucket: str, blob: str) -> bytes: + """ + Download file content using a fresh session. + + This approach avoids event loop issues that can occur when reusing + sessions across different async contexts (e.g., in executors). + + Args: + bucket: GCS bucket name + blob: Blob path within the bucket + + Returns: + File content as bytes + + Raises: + FileNotFoundError: If the file doesn't exist + """ + session = aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=10, force_close=True) + ) + client: async_gcs_storage.Storage | None = None + try: + client = async_gcs_storage.Storage(session=session) + content = await client.download(bucket, blob) + return content + except Exception as e: + if "404" in str(e) or "Not Found" in str(e): + raise FileNotFoundError(f"File not found: gcs://{bucket}/{blob}") + raise + finally: + if client: + try: + await client.close() + except Exception: + pass # Best-effort cleanup + await session.close() + + +async def generate_signed_url( + sync_client: gcs_storage.Client, + bucket_name: str, + blob_name: str, + expires_in: int, +) -> str: + """ + Generate a signed URL for temporary access to a GCS file. + + Uses asyncio.to_thread() to run the sync operation without blocking. + + Args: + sync_client: Sync GCS client with service account credentials + bucket_name: GCS bucket name + blob_name: Blob path within the bucket + expires_in: URL expiration time in seconds + + Returns: + Signed URL string + """ + bucket = sync_client.bucket(bucket_name) + blob = bucket.blob(blob_name) + return await asyncio.to_thread( + blob.generate_signed_url, + version="v4", + expiration=datetime.now(timezone.utc) + timedelta(seconds=expires_in), + method="GET", + ) diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index a42a4d29b4..aa28a4c9ac 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -263,6 +263,12 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="The name of the Google Cloud Storage bucket for media files", ) + workspace_storage_dir: str = Field( + default="", + description="Local directory for workspace file storage when GCS is not configured. " + "If empty, defaults to {app_data}/workspaces. Used for self-hosted deployments.", + ) + reddit_user_agent: str = Field( default="web:AutoGPT:v0.6.0 (by /u/autogpt)", description="The user agent for the Reddit API", @@ -389,6 +395,13 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="Maximum file size in MB for file uploads (1-1024 MB)", ) + max_file_size_mb: int = Field( + default=100, + ge=1, + le=1024, + description="Maximum file size in MB for workspace files (1-1024 MB)", + ) + # AutoMod configuration automod_enabled: bool = Field( default=False, diff --git a/autogpt_platform/backend/backend/util/test.py b/autogpt_platform/backend/backend/util/test.py index 0a539644ee..23d7c24147 100644 --- a/autogpt_platform/backend/backend/util/test.py +++ b/autogpt_platform/backend/backend/util/test.py @@ -140,14 +140,29 @@ async def execute_block_test(block: Block): setattr(block, mock_name, mock_obj) # Populate credentials argument(s) + # Generate IDs for execution context + graph_id = str(uuid.uuid4()) + node_id = str(uuid.uuid4()) + graph_exec_id = str(uuid.uuid4()) + node_exec_id = str(uuid.uuid4()) + user_id = str(uuid.uuid4()) + graph_version = 1 # Default version for tests + extra_exec_kwargs: dict = { - "graph_id": str(uuid.uuid4()), - "node_id": str(uuid.uuid4()), - "graph_exec_id": str(uuid.uuid4()), - "node_exec_id": str(uuid.uuid4()), - "user_id": str(uuid.uuid4()), - "graph_version": 1, # Default version for tests - "execution_context": ExecutionContext(), + "graph_id": graph_id, + "node_id": node_id, + "graph_exec_id": graph_exec_id, + "node_exec_id": node_exec_id, + "user_id": user_id, + "graph_version": graph_version, + "execution_context": ExecutionContext( + user_id=user_id, + graph_id=graph_id, + graph_exec_id=graph_exec_id, + graph_version=graph_version, + node_id=node_id, + node_exec_id=node_exec_id, + ), } input_model = cast(type[BlockSchema], block.input_schema) diff --git a/autogpt_platform/backend/backend/util/workspace.py b/autogpt_platform/backend/backend/util/workspace.py new file mode 100644 index 0000000000..a2f1a61b9e --- /dev/null +++ b/autogpt_platform/backend/backend/util/workspace.py @@ -0,0 +1,419 @@ +""" +WorkspaceManager for managing user workspace file operations. + +This module provides a high-level interface for workspace file operations, +combining the storage backend and database layer. +""" + +import logging +import mimetypes +import uuid +from typing import Optional + +from prisma.errors import UniqueViolationError +from prisma.models import UserWorkspaceFile + +from backend.data.workspace import ( + count_workspace_files, + create_workspace_file, + get_workspace_file, + get_workspace_file_by_path, + list_workspace_files, + soft_delete_workspace_file, +) +from backend.util.settings import Config +from backend.util.workspace_storage import compute_file_checksum, get_workspace_storage + +logger = logging.getLogger(__name__) + + +class WorkspaceManager: + """ + Manages workspace file operations. + + Combines storage backend operations with database record management. + Supports session-scoped file segmentation where files are stored in + session-specific virtual paths: /sessions/{session_id}/{filename} + """ + + def __init__( + self, user_id: str, workspace_id: str, session_id: Optional[str] = None + ): + """ + Initialize WorkspaceManager. + + Args: + user_id: The user's ID + workspace_id: The workspace ID + session_id: Optional session ID for session-scoped file access + """ + self.user_id = user_id + self.workspace_id = workspace_id + self.session_id = session_id + # Session path prefix for file isolation + self.session_path = f"/sessions/{session_id}" if session_id else "" + + def _resolve_path(self, path: str) -> str: + """ + Resolve a path, defaulting to session folder if session_id is set. + + Cross-session access is allowed by explicitly using /sessions/other-session-id/... + + Args: + path: Virtual path (e.g., "/file.txt" or "/sessions/abc123/file.txt") + + Returns: + Resolved path with session prefix if applicable + """ + # If path explicitly references a session folder, use it as-is + if path.startswith("/sessions/"): + return path + + # If we have a session context, prepend session path + if self.session_path: + # Normalize the path + if not path.startswith("/"): + path = f"/{path}" + return f"{self.session_path}{path}" + + # No session context, use path as-is + return path if path.startswith("/") else f"/{path}" + + def _get_effective_path( + self, path: Optional[str], include_all_sessions: bool + ) -> Optional[str]: + """ + Get effective path for list/count operations based on session context. + + Args: + path: Optional path prefix to filter + include_all_sessions: If True, don't apply session scoping + + Returns: + Effective path prefix for database query + """ + if include_all_sessions: + # Normalize path to ensure leading slash (stored paths are normalized) + if path is not None and not path.startswith("/"): + return f"/{path}" + return path + elif path is not None: + # Resolve the provided path with session scoping + return self._resolve_path(path) + elif self.session_path: + # Default to session folder with trailing slash to prevent prefix collisions + # e.g., "/sessions/abc" should not match "/sessions/abc123" + return self.session_path.rstrip("/") + "/" + else: + # No session context, use path as-is + return path + + async def read_file(self, path: str) -> bytes: + """ + Read file from workspace by virtual path. + + When session_id is set, paths are resolved relative to the session folder + unless they explicitly reference /sessions/... + + Args: + path: Virtual path (e.g., "/documents/report.pdf") + + Returns: + File content as bytes + + Raises: + FileNotFoundError: If file doesn't exist + """ + resolved_path = self._resolve_path(path) + file = await get_workspace_file_by_path(self.workspace_id, resolved_path) + if file is None: + raise FileNotFoundError(f"File not found at path: {resolved_path}") + + storage = await get_workspace_storage() + return await storage.retrieve(file.storagePath) + + async def read_file_by_id(self, file_id: str) -> bytes: + """ + Read file from workspace by file ID. + + Args: + file_id: The file's ID + + Returns: + File content as bytes + + Raises: + FileNotFoundError: If file doesn't exist + """ + file = await get_workspace_file(file_id, self.workspace_id) + if file is None: + raise FileNotFoundError(f"File not found: {file_id}") + + storage = await get_workspace_storage() + return await storage.retrieve(file.storagePath) + + async def write_file( + self, + content: bytes, + filename: str, + path: Optional[str] = None, + mime_type: Optional[str] = None, + overwrite: bool = False, + ) -> UserWorkspaceFile: + """ + Write file to workspace. + + When session_id is set, files are written to /sessions/{session_id}/... + by default. Use explicit /sessions/... paths for cross-session access. + + Args: + content: File content as bytes + filename: Filename for the file + path: Virtual path (defaults to "/{filename}", session-scoped if session_id set) + mime_type: MIME type (auto-detected if not provided) + overwrite: Whether to overwrite existing file at path + + Returns: + Created UserWorkspaceFile instance + + Raises: + ValueError: If file exceeds size limit or path already exists + """ + # Enforce file size limit + max_file_size = Config().max_file_size_mb * 1024 * 1024 + if len(content) > max_file_size: + raise ValueError( + f"File too large: {len(content)} bytes exceeds " + f"{Config().max_file_size_mb}MB limit" + ) + + # Determine path with session scoping + if path is None: + path = f"/{filename}" + elif not path.startswith("/"): + path = f"/{path}" + + # Resolve path with session prefix + path = self._resolve_path(path) + + # Check if file exists at path (only error for non-overwrite case) + # For overwrite=True, we let the write proceed and handle via UniqueViolationError + # This ensures the new file is written to storage BEFORE the old one is deleted, + # preventing data loss if the new write fails + if not overwrite: + existing = await get_workspace_file_by_path(self.workspace_id, path) + if existing is not None: + raise ValueError(f"File already exists at path: {path}") + + # Auto-detect MIME type if not provided + if mime_type is None: + mime_type, _ = mimetypes.guess_type(filename) + mime_type = mime_type or "application/octet-stream" + + # Compute checksum + checksum = compute_file_checksum(content) + + # Generate unique file ID for storage + file_id = str(uuid.uuid4()) + + # Store file in storage backend + storage = await get_workspace_storage() + storage_path = await storage.store( + workspace_id=self.workspace_id, + file_id=file_id, + filename=filename, + content=content, + ) + + # Create database record - handle race condition where another request + # created a file at the same path between our check and create + try: + file = await create_workspace_file( + workspace_id=self.workspace_id, + file_id=file_id, + name=filename, + path=path, + storage_path=storage_path, + mime_type=mime_type, + size_bytes=len(content), + checksum=checksum, + ) + except UniqueViolationError: + # Race condition: another request created a file at this path + if overwrite: + # Re-fetch and delete the conflicting file, then retry + existing = await get_workspace_file_by_path(self.workspace_id, path) + if existing: + await self.delete_file(existing.id) + # Retry the create - if this also fails, clean up storage file + try: + file = await create_workspace_file( + workspace_id=self.workspace_id, + file_id=file_id, + name=filename, + path=path, + storage_path=storage_path, + mime_type=mime_type, + size_bytes=len(content), + checksum=checksum, + ) + except Exception: + # Clean up orphaned storage file on retry failure + try: + await storage.delete(storage_path) + except Exception as e: + logger.warning(f"Failed to clean up orphaned storage file: {e}") + raise + else: + # Clean up the orphaned storage file before raising + try: + await storage.delete(storage_path) + except Exception as e: + logger.warning(f"Failed to clean up orphaned storage file: {e}") + raise ValueError(f"File already exists at path: {path}") + except Exception: + # Any other database error (connection, validation, etc.) - clean up storage + try: + await storage.delete(storage_path) + except Exception as e: + logger.warning(f"Failed to clean up orphaned storage file: {e}") + raise + + logger.info( + f"Wrote file {file.id} ({filename}) to workspace {self.workspace_id} " + f"at path {path}, size={len(content)} bytes" + ) + + return file + + async def list_files( + self, + path: Optional[str] = None, + limit: Optional[int] = None, + offset: int = 0, + include_all_sessions: bool = False, + ) -> list[UserWorkspaceFile]: + """ + List files in workspace. + + When session_id is set and include_all_sessions is False (default), + only files in the current session's folder are listed. + + Args: + path: Optional path prefix to filter (e.g., "/documents/") + limit: Maximum number of files to return + offset: Number of files to skip + include_all_sessions: If True, list files from all sessions. + If False (default), only list current session's files. + + Returns: + List of UserWorkspaceFile instances + """ + effective_path = self._get_effective_path(path, include_all_sessions) + + return await list_workspace_files( + workspace_id=self.workspace_id, + path_prefix=effective_path, + limit=limit, + offset=offset, + ) + + async def delete_file(self, file_id: str) -> bool: + """ + Delete a file (soft-delete). + + Args: + file_id: The file's ID + + Returns: + True if deleted, False if not found + """ + file = await get_workspace_file(file_id, self.workspace_id) + if file is None: + return False + + # Delete from storage + storage = await get_workspace_storage() + try: + await storage.delete(file.storagePath) + except Exception as e: + logger.warning(f"Failed to delete file from storage: {e}") + # Continue with database soft-delete even if storage delete fails + + # Soft-delete database record + result = await soft_delete_workspace_file(file_id, self.workspace_id) + return result is not None + + async def get_download_url(self, file_id: str, expires_in: int = 3600) -> str: + """ + Get download URL for a file. + + Args: + file_id: The file's ID + expires_in: URL expiration in seconds (default 1 hour) + + Returns: + Download URL (signed URL for GCS, API endpoint for local) + + Raises: + FileNotFoundError: If file doesn't exist + """ + file = await get_workspace_file(file_id, self.workspace_id) + if file is None: + raise FileNotFoundError(f"File not found: {file_id}") + + storage = await get_workspace_storage() + return await storage.get_download_url(file.storagePath, expires_in) + + async def get_file_info(self, file_id: str) -> Optional[UserWorkspaceFile]: + """ + Get file metadata. + + Args: + file_id: The file's ID + + Returns: + UserWorkspaceFile instance or None + """ + return await get_workspace_file(file_id, self.workspace_id) + + async def get_file_info_by_path(self, path: str) -> Optional[UserWorkspaceFile]: + """ + Get file metadata by path. + + When session_id is set, paths are resolved relative to the session folder + unless they explicitly reference /sessions/... + + Args: + path: Virtual path + + Returns: + UserWorkspaceFile instance or None + """ + resolved_path = self._resolve_path(path) + return await get_workspace_file_by_path(self.workspace_id, resolved_path) + + async def get_file_count( + self, + path: Optional[str] = None, + include_all_sessions: bool = False, + ) -> int: + """ + Get number of files in workspace. + + When session_id is set and include_all_sessions is False (default), + only counts files in the current session's folder. + + Args: + path: Optional path prefix to filter (e.g., "/documents/") + include_all_sessions: If True, count all files in workspace. + If False (default), only count current session's files. + + Returns: + Number of files + """ + effective_path = self._get_effective_path(path, include_all_sessions) + + return await count_workspace_files( + self.workspace_id, path_prefix=effective_path + ) diff --git a/autogpt_platform/backend/backend/util/workspace_storage.py b/autogpt_platform/backend/backend/util/workspace_storage.py new file mode 100644 index 0000000000..2f4c8ae2b5 --- /dev/null +++ b/autogpt_platform/backend/backend/util/workspace_storage.py @@ -0,0 +1,398 @@ +""" +Workspace storage backend abstraction for supporting both cloud and local deployments. + +This module provides a unified interface for storing workspace files, with implementations +for Google Cloud Storage (cloud deployments) and local filesystem (self-hosted deployments). +""" + +import asyncio +import hashlib +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import aiofiles +import aiohttp +from gcloud.aio import storage as async_gcs_storage +from google.cloud import storage as gcs_storage + +from backend.util.data import get_data_path +from backend.util.gcs_utils import ( + download_with_fresh_session, + generate_signed_url, + parse_gcs_path, +) +from backend.util.settings import Config + +logger = logging.getLogger(__name__) + + +class WorkspaceStorageBackend(ABC): + """Abstract interface for workspace file storage.""" + + @abstractmethod + async def store( + self, + workspace_id: str, + file_id: str, + filename: str, + content: bytes, + ) -> str: + """ + Store file content, return storage path. + + Args: + workspace_id: The workspace ID + file_id: Unique file ID for storage + filename: Original filename + content: File content as bytes + + Returns: + Storage path string (cloud path or local path) + """ + pass + + @abstractmethod + async def retrieve(self, storage_path: str) -> bytes: + """ + Retrieve file content from storage. + + Args: + storage_path: The storage path returned from store() + + Returns: + File content as bytes + """ + pass + + @abstractmethod + async def delete(self, storage_path: str) -> None: + """ + Delete file from storage. + + Args: + storage_path: The storage path to delete + """ + pass + + @abstractmethod + async def get_download_url(self, storage_path: str, expires_in: int = 3600) -> str: + """ + Get URL for downloading the file. + + Args: + storage_path: The storage path + expires_in: URL expiration time in seconds (default 1 hour) + + Returns: + Download URL (signed URL for GCS, direct API path for local) + """ + pass + + +class GCSWorkspaceStorage(WorkspaceStorageBackend): + """Google Cloud Storage implementation for workspace storage.""" + + def __init__(self, bucket_name: str): + self.bucket_name = bucket_name + self._async_client: Optional[async_gcs_storage.Storage] = None + self._sync_client: Optional[gcs_storage.Client] = None + self._session: Optional[aiohttp.ClientSession] = None + + async def _get_async_client(self) -> async_gcs_storage.Storage: + """Get or create async GCS client.""" + if self._async_client is None: + self._session = aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=100, force_close=False) + ) + self._async_client = async_gcs_storage.Storage(session=self._session) + return self._async_client + + def _get_sync_client(self) -> gcs_storage.Client: + """Get or create sync GCS client (for signed URLs).""" + if self._sync_client is None: + self._sync_client = gcs_storage.Client() + return self._sync_client + + async def close(self) -> None: + """Close all client connections.""" + if self._async_client is not None: + try: + await self._async_client.close() + except Exception as e: + logger.warning(f"Error closing GCS client: {e}") + self._async_client = None + + if self._session is not None: + try: + await self._session.close() + except Exception as e: + logger.warning(f"Error closing session: {e}") + self._session = None + + def _build_blob_name(self, workspace_id: str, file_id: str, filename: str) -> str: + """Build the blob path for workspace files.""" + return f"workspaces/{workspace_id}/{file_id}/{filename}" + + async def store( + self, + workspace_id: str, + file_id: str, + filename: str, + content: bytes, + ) -> str: + """Store file in GCS.""" + client = await self._get_async_client() + blob_name = self._build_blob_name(workspace_id, file_id, filename) + + # Upload with metadata + upload_time = datetime.now(timezone.utc) + await client.upload( + self.bucket_name, + blob_name, + content, + metadata={ + "uploaded_at": upload_time.isoformat(), + "workspace_id": workspace_id, + "file_id": file_id, + }, + ) + + return f"gcs://{self.bucket_name}/{blob_name}" + + async def retrieve(self, storage_path: str) -> bytes: + """Retrieve file from GCS.""" + bucket_name, blob_name = parse_gcs_path(storage_path) + return await download_with_fresh_session(bucket_name, blob_name) + + async def delete(self, storage_path: str) -> None: + """Delete file from GCS.""" + bucket_name, blob_name = parse_gcs_path(storage_path) + client = await self._get_async_client() + + try: + await client.delete(bucket_name, blob_name) + except Exception as e: + if "404" not in str(e) and "Not Found" not in str(e): + raise + # File already deleted, that's fine + + async def get_download_url(self, storage_path: str, expires_in: int = 3600) -> str: + """ + Generate download URL for GCS file. + + Attempts to generate a signed URL if running with service account credentials. + Falls back to an API proxy endpoint if signed URL generation fails + (e.g., when running locally with user OAuth credentials). + """ + bucket_name, blob_name = parse_gcs_path(storage_path) + + # Extract file_id from blob_name for fallback: workspaces/{workspace_id}/{file_id}/{filename} + blob_parts = blob_name.split("/") + file_id = blob_parts[2] if len(blob_parts) >= 3 else None + + # Try to generate signed URL (requires service account credentials) + try: + sync_client = self._get_sync_client() + return await generate_signed_url( + sync_client, bucket_name, blob_name, expires_in + ) + except AttributeError as e: + # Signed URL generation requires service account with private key. + # When running with user OAuth credentials, fall back to API proxy. + if "private key" in str(e) and file_id: + logger.debug( + "Cannot generate signed URL (no service account credentials), " + "falling back to API proxy endpoint" + ) + return f"/api/workspace/files/{file_id}/download" + raise + + +class LocalWorkspaceStorage(WorkspaceStorageBackend): + """Local filesystem implementation for workspace storage (self-hosted deployments).""" + + def __init__(self, base_dir: Optional[str] = None): + """ + Initialize local storage backend. + + Args: + base_dir: Base directory for workspace storage. + If None, defaults to {app_data}/workspaces + """ + if base_dir: + self.base_dir = Path(base_dir) + else: + self.base_dir = Path(get_data_path()) / "workspaces" + + # Ensure base directory exists + self.base_dir.mkdir(parents=True, exist_ok=True) + + def _build_file_path(self, workspace_id: str, file_id: str, filename: str) -> Path: + """Build the local file path with path traversal protection.""" + # Import here to avoid circular import + # (file.py imports workspace.py which imports workspace_storage.py) + from backend.util.file import sanitize_filename + + # Sanitize filename to prevent path traversal (removes / and \ among others) + safe_filename = sanitize_filename(filename) + file_path = (self.base_dir / workspace_id / file_id / safe_filename).resolve() + + # Verify the resolved path is still under base_dir + if not file_path.is_relative_to(self.base_dir.resolve()): + raise ValueError("Invalid filename: path traversal detected") + + return file_path + + def _parse_storage_path(self, storage_path: str) -> Path: + """Parse local storage path to filesystem path.""" + if storage_path.startswith("local://"): + relative_path = storage_path[8:] # Remove "local://" + else: + relative_path = storage_path + + full_path = (self.base_dir / relative_path).resolve() + + # Security check: ensure path is under base_dir + # Use is_relative_to() for robust path containment check + # (handles case-insensitive filesystems and edge cases) + if not full_path.is_relative_to(self.base_dir.resolve()): + raise ValueError("Invalid storage path: path traversal detected") + + return full_path + + async def store( + self, + workspace_id: str, + file_id: str, + filename: str, + content: bytes, + ) -> str: + """Store file locally.""" + file_path = self._build_file_path(workspace_id, file_id, filename) + + # Create parent directories + file_path.parent.mkdir(parents=True, exist_ok=True) + + # Write file asynchronously + async with aiofiles.open(file_path, "wb") as f: + await f.write(content) + + # Return relative path as storage path + relative_path = file_path.relative_to(self.base_dir) + return f"local://{relative_path}" + + async def retrieve(self, storage_path: str) -> bytes: + """Retrieve file from local storage.""" + file_path = self._parse_storage_path(storage_path) + + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {storage_path}") + + async with aiofiles.open(file_path, "rb") as f: + return await f.read() + + async def delete(self, storage_path: str) -> None: + """Delete file from local storage.""" + file_path = self._parse_storage_path(storage_path) + + if file_path.exists(): + # Remove file + file_path.unlink() + + # Clean up empty parent directories + parent = file_path.parent + while parent != self.base_dir: + try: + if parent.exists() and not any(parent.iterdir()): + parent.rmdir() + else: + break + except OSError: + break + parent = parent.parent + + async def get_download_url(self, storage_path: str, expires_in: int = 3600) -> str: + """ + Get download URL for local file. + + For local storage, this returns an API endpoint path. + The actual serving is handled by the API layer. + """ + # Parse the storage path to get the components + if storage_path.startswith("local://"): + relative_path = storage_path[8:] + else: + relative_path = storage_path + + # Return the API endpoint for downloading + # The file_id is extracted from the path: {workspace_id}/{file_id}/{filename} + parts = relative_path.split("/") + if len(parts) >= 2: + file_id = parts[1] # Second component is file_id + return f"/api/workspace/files/{file_id}/download" + else: + raise ValueError(f"Invalid storage path format: {storage_path}") + + +# Global storage backend instance +_workspace_storage: Optional[WorkspaceStorageBackend] = None +_storage_lock = asyncio.Lock() + + +async def get_workspace_storage() -> WorkspaceStorageBackend: + """ + Get the workspace storage backend instance. + + Uses GCS if media_gcs_bucket_name is configured, otherwise uses local storage. + """ + global _workspace_storage + + if _workspace_storage is None: + async with _storage_lock: + if _workspace_storage is None: + config = Config() + + if config.media_gcs_bucket_name: + logger.info( + f"Using GCS workspace storage: {config.media_gcs_bucket_name}" + ) + _workspace_storage = GCSWorkspaceStorage( + config.media_gcs_bucket_name + ) + else: + storage_dir = ( + config.workspace_storage_dir + if config.workspace_storage_dir + else None + ) + logger.info( + f"Using local workspace storage: {storage_dir or 'default'}" + ) + _workspace_storage = LocalWorkspaceStorage(storage_dir) + + return _workspace_storage + + +async def shutdown_workspace_storage() -> None: + """ + Properly shutdown the global workspace storage backend. + + Closes aiohttp sessions and other resources for GCS backend. + Should be called during application shutdown. + """ + global _workspace_storage + + if _workspace_storage is not None: + async with _storage_lock: + if _workspace_storage is not None: + if isinstance(_workspace_storage, GCSWorkspaceStorage): + await _workspace_storage.close() + _workspace_storage = None + + +def compute_file_checksum(content: bytes) -> str: + """Compute SHA256 checksum of file content.""" + return hashlib.sha256(content).hexdigest() diff --git a/autogpt_platform/backend/migrations/20260127230419_add_user_workspace/migration.sql b/autogpt_platform/backend/migrations/20260127230419_add_user_workspace/migration.sql new file mode 100644 index 0000000000..bb63dccb33 --- /dev/null +++ b/autogpt_platform/backend/migrations/20260127230419_add_user_workspace/migration.sql @@ -0,0 +1,52 @@ +-- CreateEnum +CREATE TYPE "WorkspaceFileSource" AS ENUM ('UPLOAD', 'EXECUTION', 'COPILOT', 'IMPORT'); + +-- CreateTable +CREATE TABLE "UserWorkspace" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "UserWorkspace_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserWorkspaceFile" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "workspaceId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "path" TEXT NOT NULL, + "storagePath" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" BIGINT NOT NULL, + "checksum" TEXT, + "isDeleted" BOOLEAN NOT NULL DEFAULT false, + "deletedAt" TIMESTAMP(3), + "source" "WorkspaceFileSource" NOT NULL DEFAULT 'UPLOAD', + "sourceExecId" TEXT, + "sourceSessionId" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + + CONSTRAINT "UserWorkspaceFile_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserWorkspace_userId_key" ON "UserWorkspace"("userId"); + +-- CreateIndex +CREATE INDEX "UserWorkspace_userId_idx" ON "UserWorkspace"("userId"); + +-- CreateIndex +CREATE INDEX "UserWorkspaceFile_workspaceId_isDeleted_idx" ON "UserWorkspaceFile"("workspaceId", "isDeleted"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserWorkspaceFile_workspaceId_path_key" ON "UserWorkspaceFile"("workspaceId", "path"); + +-- AddForeignKey +ALTER TABLE "UserWorkspace" ADD CONSTRAINT "UserWorkspace_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserWorkspaceFile" ADD CONSTRAINT "UserWorkspaceFile_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "UserWorkspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20260129011611_remove_workspace_file_source/migration.sql b/autogpt_platform/backend/migrations/20260129011611_remove_workspace_file_source/migration.sql new file mode 100644 index 0000000000..2709bc8484 --- /dev/null +++ b/autogpt_platform/backend/migrations/20260129011611_remove_workspace_file_source/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `source` on the `UserWorkspaceFile` table. All the data in the column will be lost. + - You are about to drop the column `sourceExecId` on the `UserWorkspaceFile` table. All the data in the column will be lost. + - You are about to drop the column `sourceSessionId` on the `UserWorkspaceFile` table. All the data in the column will be lost. + +*/ + +-- AlterTable +ALTER TABLE "UserWorkspaceFile" DROP COLUMN "source", +DROP COLUMN "sourceExecId", +DROP COLUMN "sourceSessionId"; + +-- DropEnum +DROP TYPE "WorkspaceFileSource"; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 2c52528e3f..2da898a7ce 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -63,6 +63,7 @@ model User { IntegrationWebhooks IntegrationWebhook[] NotificationBatches UserNotificationBatch[] PendingHumanReviews PendingHumanReview[] + Workspace UserWorkspace? // OAuth Provider relations OAuthApplications OAuthApplication[] @@ -137,6 +138,53 @@ model CoPilotUnderstanding { @@index([userId]) } +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////// USER WORKSPACE TABLES ///////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// + +// User's persistent file storage workspace +model UserWorkspace { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userId String @unique + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + + Files UserWorkspaceFile[] + + @@index([userId]) +} + +// Individual files in a user's workspace +model UserWorkspaceFile { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + workspaceId String + Workspace UserWorkspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + // File metadata + name String // User-visible filename + path String // Virtual path (e.g., "/documents/report.pdf") + storagePath String // Actual GCS or local storage path + mimeType String + sizeBytes BigInt + checksum String? // SHA256 for integrity + + // File state + isDeleted Boolean @default(false) + deletedAt DateTime? + + metadata Json @default("{}") + + @@unique([workspaceId, path]) + @@index([workspaceId, isDeleted]) +} + model BuilderSearchHistory { id String @id @default(uuid()) createdAt DateTime @default(now()) diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 2a9db1990d..6692c30e72 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -5912,6 +5912,40 @@ } } }, + "/api/workspace/files/{file_id}/download": { + "get": { + "tags": ["workspace"], + "summary": "Download file by ID", + "description": "Download a file by its ID.\n\nReturns the file content directly or redirects to a signed URL for GCS.", + "operationId": "getWorkspaceDownload file by id", + "security": [{ "HTTPBearerJWT": [] }], + "parameters": [ + { + "name": "file_id", + "in": "path", + "required": true, + "schema": { "type": "string", "title": "File Id" } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { "application/json": { "schema": {} } } + }, + "401": { + "$ref": "#/components/responses/HTTP401NotAuthenticatedError" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HTTPValidationError" } + } + } + } + } + } + }, "/health": { "get": { "tags": ["health"], diff --git a/autogpt_platform/frontend/src/app/api/proxy/[...path]/route.ts b/autogpt_platform/frontend/src/app/api/proxy/[...path]/route.ts index 293c406373..442bd77e0f 100644 --- a/autogpt_platform/frontend/src/app/api/proxy/[...path]/route.ts +++ b/autogpt_platform/frontend/src/app/api/proxy/[...path]/route.ts @@ -1,5 +1,6 @@ import { ApiError, + getServerAuthToken, makeAuthenticatedFileUpload, makeAuthenticatedRequest, } from "@/lib/autogpt-server-api/helpers"; @@ -15,6 +16,69 @@ function buildBackendUrl(path: string[], queryString: string): string { return `${environment.getAGPTServerBaseUrl()}/${backendPath}${queryString}`; } +/** + * Check if this is a workspace file download request that needs binary response handling. + */ +function isWorkspaceDownloadRequest(path: string[]): boolean { + // Match pattern: api/workspace/files/{id}/download (5 segments) + return ( + path.length == 5 && + path[0] === "api" && + path[1] === "workspace" && + path[2] === "files" && + path[path.length - 1] === "download" + ); +} + +/** + * Handle workspace file download requests with proper binary response streaming. + */ +async function handleWorkspaceDownload( + req: NextRequest, + backendUrl: string, +): Promise { + const token = await getServerAuthToken(); + + const headers: Record = {}; + if (token && token !== "no-token-found") { + headers["Authorization"] = `Bearer ${token}`; + } + + const response = await fetch(backendUrl, { + method: "GET", + headers, + redirect: "follow", // Follow redirects to signed URLs + }); + + if (!response.ok) { + return NextResponse.json( + { error: `Failed to download file: ${response.statusText}` }, + { status: response.status }, + ); + } + + // Get the content type from the backend response + const contentType = + response.headers.get("Content-Type") || "application/octet-stream"; + const contentDisposition = response.headers.get("Content-Disposition"); + + // Stream the response body + const responseHeaders: Record = { + "Content-Type": contentType, + }; + + if (contentDisposition) { + responseHeaders["Content-Disposition"] = contentDisposition; + } + + // Return the binary content + const arrayBuffer = await response.arrayBuffer(); + return new NextResponse(arrayBuffer, { + status: 200, + headers: responseHeaders, + }); +} + async function handleJsonRequest( req: NextRequest, method: string, @@ -180,6 +244,11 @@ async function handler( }; try { + // Handle workspace file downloads separately (binary response) + if (method === "GET" && isWorkspaceDownloadRequest(path)) { + return await handleWorkspaceDownload(req, backendUrl); + } + if (method === "GET" || method === "DELETE") { responseBody = await handleGetDeleteRequest(method, backendUrl, req); } else if (contentType?.includes("application/json")) { diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx index 51a0794090..3dd5eca692 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/MarkdownContent/MarkdownContent.tsx @@ -1,6 +1,8 @@ "use client"; +import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace"; import { cn } from "@/lib/utils"; +import { EyeSlash } from "@phosphor-icons/react"; import React from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -29,12 +31,88 @@ interface InputProps extends React.InputHTMLAttributes { type?: string; } +/** + * Converts a workspace:// URL to a proxy URL that routes through Next.js to the backend. + * workspace://abc123 -> /api/proxy/api/workspace/files/abc123/download + * + * Uses the generated API URL helper and routes through the Next.js proxy + * which handles authentication and proper backend routing. + */ +/** + * URL transformer for ReactMarkdown. + * Converts workspace:// URLs to proxy URLs that route through Next.js to the backend. + * workspace://abc123 -> /api/proxy/api/workspace/files/abc123/download + * + * This is needed because ReactMarkdown sanitizes URLs and only allows + * http, https, mailto, and tel protocols by default. + */ +function resolveWorkspaceUrl(src: string): string { + if (src.startsWith("workspace://")) { + const fileId = src.replace("workspace://", ""); + // Use the generated API URL helper to get the correct path + const apiPath = getGetWorkspaceDownloadFileByIdUrl(fileId); + // Route through the Next.js proxy (same pattern as customMutator for client-side) + return `/api/proxy${apiPath}`; + } + return src; +} + +/** + * Check if the image URL is a workspace file (AI cannot see these yet). + * After URL transformation, workspace files have URLs like /api/proxy/api/workspace/files/... + */ +function isWorkspaceImage(src: string | undefined): boolean { + return src?.includes("/workspace/files/") ?? false; +} + +/** + * Custom image component that shows an indicator when the AI cannot see the image. + * Note: src is already transformed by urlTransform, so workspace:// is now /api/workspace/... + */ +function MarkdownImage(props: Record) { + const src = props.src as string | undefined; + const alt = props.alt as string | undefined; + + const aiCannotSee = isWorkspaceImage(src); + + // If no src, show a placeholder + if (!src) { + return ( + + [Image: {alt || "missing src"}] + + ); + } + + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {alt + {aiCannotSee && ( + + + AI cannot see this image + + )} + + ); +} + export function MarkdownContent({ content, className }: MarkdownContentProps) { return (
{ const isInline = !className?.includes("language-"); @@ -206,6 +284,9 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) { {children} ), + img: ({ src, alt, ...props }) => ( + + ), }} > {content} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/helpers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/helpers.ts index 400f32936e..e886e1a28c 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/helpers.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ToolResponseMessage/helpers.ts @@ -37,6 +37,87 @@ export function getErrorMessage(result: unknown): string { return "An error occurred"; } +/** + * Check if a value is a workspace file reference. + */ +function isWorkspaceRef(value: unknown): value is string { + return typeof value === "string" && value.startsWith("workspace://"); +} + +/** + * Check if a workspace reference appears to be an image based on common patterns. + * Since workspace refs don't have extensions, we check the context or assume image + * for certain block types. + * + * TODO: Replace keyword matching with MIME type encoded in workspace ref. + * e.g., workspace://abc123#image/png or workspace://abc123#video/mp4 + * This would let frontend render correctly without fragile keyword matching. + */ +function isLikelyImageRef(value: string, outputKey?: string): boolean { + if (!isWorkspaceRef(value)) return false; + + // Check output key name for video-related hints (these are NOT images) + const videoKeywords = ["video", "mp4", "mov", "avi", "webm", "movie", "clip"]; + if (outputKey) { + const lowerKey = outputKey.toLowerCase(); + if (videoKeywords.some((kw) => lowerKey.includes(kw))) { + return false; + } + } + + // Check output key name for image-related hints + const imageKeywords = [ + "image", + "img", + "photo", + "picture", + "thumbnail", + "avatar", + "icon", + "screenshot", + ]; + if (outputKey) { + const lowerKey = outputKey.toLowerCase(); + if (imageKeywords.some((kw) => lowerKey.includes(kw))) { + return true; + } + } + + // Default to treating workspace refs as potential images + // since that's the most common case for generated content + return true; +} + +/** + * Format a single output value, converting workspace refs to markdown images. + */ +function formatOutputValue(value: unknown, outputKey?: string): string { + if (isWorkspaceRef(value) && isLikelyImageRef(value, outputKey)) { + // Format as markdown image + return `![${outputKey || "Generated image"}](${value})`; + } + + if (typeof value === "string") { + // Check for data URIs (images) + if (value.startsWith("data:image/")) { + return `![${outputKey || "Generated image"}](${value})`; + } + return value; + } + + if (Array.isArray(value)) { + return value + .map((item, idx) => formatOutputValue(item, `${outputKey}_${idx}`)) + .join("\n\n"); + } + + if (typeof value === "object" && value !== null) { + return JSON.stringify(value, null, 2); + } + + return String(value); +} + function getToolCompletionPhrase(toolName: string): string { const toolCompletionPhrases: Record = { add_understanding: "Updated your business information", @@ -127,10 +208,26 @@ export function formatToolResponse(result: unknown, toolName: string): string { case "block_output": const blockName = (response.block_name as string) || "Block"; - const outputs = response.outputs as Record | undefined; + const outputs = response.outputs as Record | undefined; if (outputs && Object.keys(outputs).length > 0) { - const outputKeys = Object.keys(outputs); - return `${blockName} executed successfully. Outputs: ${outputKeys.join(", ")}`; + const formattedOutputs: string[] = []; + + for (const [key, values] of Object.entries(outputs)) { + if (!Array.isArray(values) || values.length === 0) continue; + + // Format each value in the output array + for (const value of values) { + const formatted = formatOutputValue(value, key); + if (formatted) { + formattedOutputs.push(formatted); + } + } + } + + if (formattedOutputs.length > 0) { + return `${blockName} executed successfully.\n\n${formattedOutputs.join("\n\n")}`; + } + return `${blockName} executed successfully.`; } return `${blockName} executed successfully.`; diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 192405156c..263d7e6365 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -53,7 +53,7 @@ Below is a comprehensive list of all available blocks, categorized by their prim | [Block Installation](block-integrations/basic.md#block-installation) | Given a code string, this block allows the verification and installation of a block code into the system | | [Concatenate Lists](block-integrations/basic.md#concatenate-lists) | Concatenates multiple lists into a single list | | [Dictionary Is Empty](block-integrations/basic.md#dictionary-is-empty) | Checks if a dictionary is empty | -| [File Store](block-integrations/basic.md#file-store) | Stores the input file in the temporary directory | +| [File Store](block-integrations/basic.md#file-store) | Downloads and stores a file from a URL, data URI, or local path | | [Find In Dictionary](block-integrations/basic.md#find-in-dictionary) | A block that looks up a value in a dictionary, list, or object by key or index and returns the corresponding value | | [Find In List](block-integrations/basic.md#find-in-list) | Finds the index of the value in the list | | [Get All Memories](block-integrations/basic.md#get-all-memories) | Retrieve all memories from Mem0 with optional conversation filtering | diff --git a/docs/integrations/block-integrations/basic.md b/docs/integrations/block-integrations/basic.md index f92d19002f..5a73fd5a03 100644 --- a/docs/integrations/block-integrations/basic.md +++ b/docs/integrations/block-integrations/basic.md @@ -709,7 +709,7 @@ This is useful for conditional logic where you need to verify if data was return ## File Store ### What it is -Stores the input file in the temporary directory. +Downloads and stores a file from a URL, data URI, or local path. Use this to fetch images, documents, or other files for processing. In CoPilot: saves to workspace (use list_workspace_files to see it). In graphs: outputs a data URI to pass to other blocks. ### How it works @@ -722,15 +722,15 @@ The block outputs a file path that other blocks can use to access the stored fil | Input | Description | Type | Required | |-------|-------------|------|----------| -| file_in | The file to store in the temporary directory, it can be a URL, data URI, or local path. | str (file) | Yes | -| base_64 | Whether produce an output in base64 format (not recommended, you can pass the string path just fine accross blocks). | bool | No | +| file_in | The file to download and store. Can be a URL (https://...), data URI, or local path. | str (file) | Yes | +| base_64 | Whether to produce output in base64 format (not recommended, you can pass the file reference across blocks). | bool | No | ### Outputs | Output | Description | Type | |--------|-------------|------| | error | Error message if the operation failed | str | -| file_out | The relative path to the stored file in the temporary directory. | str (file) | +| file_out | Reference to the stored file. In CoPilot: workspace:// URI (visible in list_workspace_files). In graphs: data URI for passing to other blocks. | str (file) | ### Possible use case diff --git a/docs/integrations/block-integrations/multimedia.md b/docs/integrations/block-integrations/multimedia.md index e2d11cfbf7..6b8f261346 100644 --- a/docs/integrations/block-integrations/multimedia.md +++ b/docs/integrations/block-integrations/multimedia.md @@ -12,7 +12,7 @@ Block to attach an audio file to a video file using moviepy. This block combines a video file with an audio file using the moviepy library. The audio track is attached to the video, optionally with volume adjustment via the volume parameter (1.0 = original volume). -Input files can be URLs, data URIs, or local paths. The output can be returned as either a file path or base64 data URI. +Input files can be URLs, data URIs, or local paths. The output format is automatically determined: `workspace://` URLs in CoPilot, data URIs in graph executions. ### Inputs @@ -22,7 +22,6 @@ Input files can be URLs, data URIs, or local paths. The output can be returned a | video_in | Video input (URL, data URI, or local path). | str (file) | Yes | | audio_in | Audio input (URL, data URI, or local path). | str (file) | Yes | | volume | Volume scale for the newly attached audio track (1.0 = original). | float | No | -| output_return_type | Return the final output as a relative path or base64 data URI. | "file_path" \| "data_uri" | No | ### Outputs @@ -51,7 +50,7 @@ Block to loop a video to a given duration or number of repeats. This block extends a video by repeating it to reach a target duration or number of loops. Set duration to specify the total length in seconds, or use n_loops to repeat the video a specific number of times. -The looped video is seamlessly concatenated and can be output as a file path or base64 data URI. +The looped video is seamlessly concatenated. The output format is automatically determined: `workspace://` URLs in CoPilot, data URIs in graph executions. ### Inputs @@ -61,7 +60,6 @@ The looped video is seamlessly concatenated and can be output as a file path or | video_in | The input video (can be a URL, data URI, or local path). | str (file) | Yes | | duration | Target duration (in seconds) to loop the video to. If omitted, defaults to no looping. | float | No | | n_loops | Number of times to repeat the video. If omitted, defaults to 1 (no repeat). | int | No | -| output_return_type | How to return the output video. Either a relative path or base64 data URI. | "file_path" \| "data_uri" | No | ### Outputs diff --git a/docs/platform/block-sdk-guide.md b/docs/platform/block-sdk-guide.md index 5b3eda5184..42fd883251 100644 --- a/docs/platform/block-sdk-guide.md +++ b/docs/platform/block-sdk-guide.md @@ -277,6 +277,50 @@ async def run( token = credentials.api_key.get_secret_value() ``` +### Handling Files + +When your block works with files (images, videos, documents), use `store_media_file()`: + +```python +from backend.data.execution import ExecutionContext +from backend.util.file import store_media_file +from backend.util.type import MediaFileType + +async def run( + self, + input_data: Input, + *, + execution_context: ExecutionContext, + **kwargs, +): + # PROCESSING: Need local file path for tools like ffmpeg, MoviePy, PIL + local_path = await store_media_file( + file=input_data.video, + execution_context=execution_context, + return_format="for_local_processing", + ) + + # EXTERNAL API: Need base64 content for APIs like Replicate, OpenAI + image_b64 = await store_media_file( + file=input_data.image, + execution_context=execution_context, + return_format="for_external_api", + ) + + # OUTPUT: Return to user/next block (auto-adapts to context) + result = await store_media_file( + file=generated_url, + execution_context=execution_context, + return_format="for_block_output", # workspace:// in CoPilot, data URI in graphs + ) + yield "image_url", result +``` + +**Return format options:** +- `"for_local_processing"` - Local file path for processing tools +- `"for_external_api"` - Data URI for external APIs needing base64 +- `"for_block_output"` - **Always use for outputs** - automatically picks best format + ## Testing Your Block ```bash diff --git a/docs/platform/new_blocks.md b/docs/platform/new_blocks.md index d9d329ff51..114ff8d9a4 100644 --- a/docs/platform/new_blocks.md +++ b/docs/platform/new_blocks.md @@ -111,6 +111,71 @@ Follow these steps to create and test a new block: - `graph_exec_id`: The ID of the execution of the agent. This changes every time the agent has a new "run" - `node_exec_id`: The ID of the execution of the node. This changes every time the node is executed - `node_id`: The ID of the node that is being executed. It changes every version of the graph, but not every time the node is executed. + - `execution_context`: An `ExecutionContext` object containing user_id, graph_exec_id, workspace_id, and session_id. Required for file handling. + +### Handling Files in Blocks + +When your block needs to work with files (images, videos, documents), use `store_media_file()` from `backend.util.file`. This function handles downloading, validation, virus scanning, and storage. + +**Import:** +```python +from backend.data.execution import ExecutionContext +from backend.util.file import store_media_file +from backend.util.type import MediaFileType +``` + +**The `return_format` parameter determines what you get back:** + +| Format | Use When | Returns | +|--------|----------|---------| +| `"for_local_processing"` | Processing with local tools (ffmpeg, MoviePy, PIL) | Local file path (e.g., `"image.png"`) | +| `"for_external_api"` | Sending content to external APIs (Replicate, OpenAI) | Data URI (e.g., `"data:image/png;base64,..."`) | +| `"for_block_output"` | Returning output from your block | Smart: `workspace://` in CoPilot, data URI in graphs | + +**Examples:** + +```python +async def run( + self, + input_data: Input, + *, + execution_context: ExecutionContext, + **kwargs, +) -> BlockOutput: + # PROCESSING: Need to work with file locally (ffmpeg, MoviePy, PIL) + local_path = await store_media_file( + file=input_data.video, + execution_context=execution_context, + return_format="for_local_processing", + ) + # local_path = "video.mp4" - use with Path, ffmpeg, subprocess, etc. + full_path = get_exec_file_path(execution_context.graph_exec_id, local_path) + + # EXTERNAL API: Need to send content to an API like Replicate + image_b64 = await store_media_file( + file=input_data.image, + execution_context=execution_context, + return_format="for_external_api", + ) + # image_b64 = "data:image/png;base64,iVBORw0..." - send to external API + + # OUTPUT: Returning result from block to user/next block + result_url = await store_media_file( + file=generated_image_url, + execution_context=execution_context, + return_format="for_block_output", + ) + yield "image_url", result_url + # In CoPilot: result_url = "workspace://abc123" (persistent, context-efficient) + # In graphs: result_url = "data:image/png;base64,..." (for next block/display) +``` + +**Key points:** + +- `for_block_output` is the **only** format that auto-adapts to execution context +- Always use `for_block_output` for block outputs unless you have a specific reason not to +- Never manually check for `workspace_id` - let `for_block_output` handle the logic +- The function handles URLs, data URIs, `workspace://` references, and local paths as input ### Field Types From b94c83aacc9093ce480aa270971ac5baa177f311 Mon Sep 17 00:00:00 2001 From: Ubbe Date: Thu, 29 Jan 2026 17:46:36 +0700 Subject: [PATCH 32/36] feat(frontend): Copilot speech to text via Whisper model (#11871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes šŸ—ļø https://github.com/user-attachments/assets/d9c12ac0-625c-4b38-8834-e494b5eda9c0 Add a "speech to text" feature in the Chat input fox of Copilot, similar as what you have in ChatGPT. ## Checklist šŸ“‹ ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Run locally and try the speech to text feature as part of the chat input box ### For configuration changes: We need to add `OPENAI_API_KEY=` to Vercel ( used in the Front-end ) both in Dev and Prod. - [x] `.env.default` is updated or already compatible with my changes --------- Co-authored-by: Claude Opus 4.5 --- AGENTS.md | 24 +- autogpt_platform/CLAUDE.md | 16 +- autogpt_platform/frontend/.env.default | 3 + .../SessionsList/useSessionsPagination.ts | 4 +- .../frontend/src/app/api/transcribe/route.ts | 77 ++++++ .../Chat/components/ChatInput/ChatInput.tsx | 157 +++++++++--- .../ChatInput/components/AudioWaveform.tsx | 142 +++++++++++ .../components/RecordingIndicator.tsx | 26 ++ .../Chat/components/ChatInput/helpers.ts | 6 + .../Chat/components/ChatInput/useChatInput.ts | 4 +- .../components/ChatInput/useVoiceRecording.ts | 240 ++++++++++++++++++ 11 files changed, 626 insertions(+), 73 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/api/transcribe/route.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/components/AudioWaveform.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/components/RecordingIndicator.tsx create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/helpers.ts create mode 100644 autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useVoiceRecording.ts diff --git a/AGENTS.md b/AGENTS.md index cd176f8a2d..202c4c6e02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,6 @@ See `docs/content/platform/getting-started.md` for setup instructions. - Format Python code with `poetry run format`. - Format frontend code using `pnpm format`. - ## Frontend guidelines: See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference: @@ -33,14 +32,17 @@ See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference: 4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only 5. **Testing**: Add Storybook stories for new components, Playwright for E2E 6. **Code conventions**: Function declarations (not arrow functions) for components/handlers + - Component props should be `interface Props { ... }` (not exported) unless the interface needs to be used outside the component - Separate render logic from business logic (component.tsx + useComponent.ts + helpers.ts) - Colocate state when possible and avoid creating large components, use sub-components ( local `/components` folder next to the parent component ) when sensible - Avoid large hooks, abstract logic into `helpers.ts` files when sensible - Use function declarations for components, arrow functions only for callbacks - No barrel files or `index.ts` re-exports -- Do not use `useCallback` or `useMemo` unless strictly needed - Avoid comments at all times unless the code is very complex +- Do not use `useCallback` or `useMemo` unless asked to optimise a given function +- Do not type hook returns, let Typescript infer as much as possible +- Never type with `any`, if not types available use `unknown` ## Testing @@ -49,22 +51,8 @@ See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference: Always run the relevant linters and tests before committing. Use conventional commit messages for all commits (e.g. `feat(backend): add API`). - Types: - - feat - - fix - - refactor - - ci - - dx (developer experience) - Scopes: - - platform - - platform/library - - platform/marketplace - - backend - - backend/executor - - frontend - - frontend/library - - frontend/marketplace - - blocks +Types: - feat - fix - refactor - ci - dx (developer experience) +Scopes: - platform - platform/library - platform/marketplace - backend - backend/executor - frontend - frontend/library - frontend/marketplace - blocks ## Pull requests diff --git a/autogpt_platform/CLAUDE.md b/autogpt_platform/CLAUDE.md index 9690178587..a5a588b667 100644 --- a/autogpt_platform/CLAUDE.md +++ b/autogpt_platform/CLAUDE.md @@ -85,17 +85,6 @@ pnpm format pnpm types ``` -**šŸ“– Complete Guide**: See `/frontend/CONTRIBUTING.md` and `/frontend/.cursorrules` for comprehensive frontend patterns. - -**Key Frontend Conventions:** - -- Separate render logic from data/behavior in components -- Use generated API hooks from `@/app/api/__generated__/endpoints/` -- Use function declarations (not arrow functions) for components/handlers -- Use design system components from `src/components/` (atoms, molecules, organisms) -- Only use Phosphor Icons -- Never use `src/components/__legacy__/*` or deprecated `BackendAPI` - ## Architecture Overview ### Backend Architecture @@ -261,14 +250,17 @@ See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference: 4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only 5. **Testing**: Add Storybook stories for new components, Playwright for E2E 6. **Code conventions**: Function declarations (not arrow functions) for components/handlers + - Component props should be `interface Props { ... }` (not exported) unless the interface needs to be used outside the component - Separate render logic from business logic (component.tsx + useComponent.ts + helpers.ts) - Colocate state when possible and avoid creating large components, use sub-components ( local `/components` folder next to the parent component ) when sensible - Avoid large hooks, abstract logic into `helpers.ts` files when sensible - Use function declarations for components, arrow functions only for callbacks - No barrel files or `index.ts` re-exports -- Do not use `useCallback` or `useMemo` unless strictly needed +- Do not use `useCallback` or `useMemo` unless asked to optimise a given function - Avoid comments at all times unless the code is very complex +- Do not type hook returns, let Typescript infer as much as possible +- Never type with `any`, if not types available use `unknown` ### Security Implementation diff --git a/autogpt_platform/frontend/.env.default b/autogpt_platform/frontend/.env.default index af250fb8bf..7a9d81e39e 100644 --- a/autogpt_platform/frontend/.env.default +++ b/autogpt_platform/frontend/.env.default @@ -34,3 +34,6 @@ NEXT_PUBLIC_PREVIEW_STEALING_DEV= # PostHog Analytics NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com + +# OpenAI (for voice transcription) +OPENAI_API_KEY= diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts index 11ddd937af..61e3e6f37f 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts @@ -73,9 +73,9 @@ export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) { }; const reset = () => { + // Only reset the offset - keep existing sessions visible during refetch + // The effect will replace sessions when new data arrives at offset 0 setOffset(0); - setAccumulatedSessions([]); - setTotalCount(null); }; return { diff --git a/autogpt_platform/frontend/src/app/api/transcribe/route.ts b/autogpt_platform/frontend/src/app/api/transcribe/route.ts new file mode 100644 index 0000000000..10c182cdfa --- /dev/null +++ b/autogpt_platform/frontend/src/app/api/transcribe/route.ts @@ -0,0 +1,77 @@ +import { getServerAuthToken } from "@/lib/autogpt-server-api/helpers"; +import { NextRequest, NextResponse } from "next/server"; + +const WHISPER_API_URL = "https://api.openai.com/v1/audio/transcriptions"; +const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB - Whisper's limit + +function getExtensionFromMimeType(mimeType: string): string { + const subtype = mimeType.split("/")[1]?.split(";")[0]; + return subtype || "webm"; +} + +export async function POST(request: NextRequest) { + const token = await getServerAuthToken(); + + if (!token || token === "no-token-found") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const apiKey = process.env.OPENAI_API_KEY; + + if (!apiKey) { + return NextResponse.json( + { error: "OpenAI API key not configured" }, + { status: 401 }, + ); + } + + try { + const formData = await request.formData(); + const audioFile = formData.get("audio"); + + if (!audioFile || !(audioFile instanceof Blob)) { + return NextResponse.json( + { error: "No audio file provided" }, + { status: 400 }, + ); + } + + if (audioFile.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "File too large. Maximum size is 25MB." }, + { status: 413 }, + ); + } + + const ext = getExtensionFromMimeType(audioFile.type); + const whisperFormData = new FormData(); + whisperFormData.append("file", audioFile, `recording.${ext}`); + whisperFormData.append("model", "whisper-1"); + + const response = await fetch(WHISPER_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + body: whisperFormData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error("Whisper API error:", errorData); + return NextResponse.json( + { error: errorData.error?.message || "Transcription failed" }, + { status: response.status }, + ); + } + + const result = await response.json(); + return NextResponse.json({ text: result.text }); + } catch (error) { + console.error("Transcription error:", error); + return NextResponse.json( + { error: "Failed to process audio" }, + { status: 500 }, + ); + } +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx index c45e8dc250..521f6f6320 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/ChatInput.tsx @@ -1,7 +1,14 @@ import { Button } from "@/components/atoms/Button/Button"; import { cn } from "@/lib/utils"; -import { ArrowUpIcon, StopIcon } from "@phosphor-icons/react"; +import { + ArrowUpIcon, + CircleNotchIcon, + MicrophoneIcon, + StopIcon, +} from "@phosphor-icons/react"; +import { RecordingIndicator } from "./components/RecordingIndicator"; import { useChatInput } from "./useChatInput"; +import { useVoiceRecording } from "./useVoiceRecording"; export interface Props { onSend: (message: string) => void; @@ -21,13 +28,36 @@ export function ChatInput({ className, }: Props) { const inputId = "chat-input"; - const { value, handleKeyDown, handleSubmit, handleChange, hasMultipleLines } = - useChatInput({ - onSend, - disabled: disabled || isStreaming, - maxRows: 4, - inputId, - }); + const { + value, + setValue, + handleKeyDown: baseHandleKeyDown, + handleSubmit, + handleChange, + hasMultipleLines, + } = useChatInput({ + onSend, + disabled: disabled || isStreaming, + maxRows: 4, + inputId, + }); + + const { + isRecording, + isTranscribing, + elapsedTime, + toggleRecording, + handleKeyDown, + showMicButton, + isInputDisabled, + audioStream, + } = useVoiceRecording({ + setValue, + disabled: disabled || isStreaming, + isStreaming, + value, + baseHandleKeyDown, + }); return ( @@ -35,8 +65,11 @@ export function ChatInput({
@@ -46,48 +79,94 @@ export function ChatInput({ value={value} onChange={handleChange} onKeyDown={handleKeyDown} - placeholder={placeholder} - disabled={disabled || isStreaming} + placeholder={ + isTranscribing + ? "Transcribing..." + : isRecording + ? "" + : placeholder + } + disabled={isInputDisabled} rows={1} className={cn( "w-full resize-none overflow-y-auto border-0 bg-transparent text-[1rem] leading-6 text-black", "placeholder:text-zinc-400", "focus:outline-none focus:ring-0", "disabled:text-zinc-500", - hasMultipleLines ? "pb-6 pl-4 pr-4 pt-2" : "pb-4 pl-4 pr-14 pt-4", + hasMultipleLines + ? "pb-6 pl-4 pr-4 pt-2" + : showMicButton + ? "pb-4 pl-14 pr-14 pt-4" + : "pb-4 pl-4 pr-14 pt-4", )} /> + {isRecording && !value && ( +
+ +
+ )}
- Press Enter to send, Shift+Enter for new line + Press Enter to send, Shift+Enter for new line, Space to record voice - {isStreaming ? ( - - ) : ( - + {showMicButton && ( +
+ +
)} + +
+ {isStreaming ? ( + + ) : ( + + )} +
); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/components/AudioWaveform.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/components/AudioWaveform.tsx new file mode 100644 index 0000000000..10cbb3fc9f --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/components/AudioWaveform.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface Props { + stream: MediaStream | null; + barCount?: number; + barWidth?: number; + barGap?: number; + barColor?: string; + minBarHeight?: number; + maxBarHeight?: number; +} + +export function AudioWaveform({ + stream, + barCount = 24, + barWidth = 3, + barGap = 2, + barColor = "#ef4444", // red-500 + minBarHeight = 4, + maxBarHeight = 32, +}: Props) { + const [bars, setBars] = useState(() => + Array(barCount).fill(minBarHeight), + ); + const analyserRef = useRef(null); + const audioContextRef = useRef(null); + const sourceRef = useRef(null); + const animationRef = useRef(null); + + useEffect(() => { + if (!stream) { + setBars(Array(barCount).fill(minBarHeight)); + return; + } + + // Create audio context and analyser + const audioContext = new AudioContext(); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + analyser.smoothingTimeConstant = 0.8; + + // Connect the stream to the analyser + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + + audioContextRef.current = audioContext; + analyserRef.current = analyser; + sourceRef.current = source; + + const timeData = new Uint8Array(analyser.frequencyBinCount); + + const updateBars = () => { + if (!analyserRef.current) return; + + analyserRef.current.getByteTimeDomainData(timeData); + + // Distribute time-domain data across bars + // This shows waveform amplitude, making all bars respond to audio + const newBars: number[] = []; + const samplesPerBar = timeData.length / barCount; + + for (let i = 0; i < barCount; i++) { + // Sample waveform data for this bar + let maxAmplitude = 0; + const startIdx = Math.floor(i * samplesPerBar); + const endIdx = Math.floor((i + 1) * samplesPerBar); + + for (let j = startIdx; j < endIdx && j < timeData.length; j++) { + // Convert to amplitude (distance from center 128) + const amplitude = Math.abs(timeData[j] - 128); + maxAmplitude = Math.max(maxAmplitude, amplitude); + } + + // Map amplitude (0-128) to bar height + const normalized = (maxAmplitude / 128) * 255; + const height = + minBarHeight + (normalized / 255) * (maxBarHeight - minBarHeight); + newBars.push(height); + } + + setBars(newBars); + animationRef.current = requestAnimationFrame(updateBars); + }; + + updateBars(); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + if (sourceRef.current) { + sourceRef.current.disconnect(); + } + if (audioContextRef.current) { + audioContextRef.current.close(); + } + analyserRef.current = null; + audioContextRef.current = null; + sourceRef.current = null; + }; + }, [stream, barCount, minBarHeight, maxBarHeight]); + + const totalWidth = barCount * barWidth + (barCount - 1) * barGap; + + return ( +
+ {bars.map((height, i) => { + const barHeight = Math.max(minBarHeight, height); + return ( +
+
+
+ ); + })} +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/components/RecordingIndicator.tsx b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/components/RecordingIndicator.tsx new file mode 100644 index 0000000000..0be0d069bb --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/components/RecordingIndicator.tsx @@ -0,0 +1,26 @@ +import { formatElapsedTime } from "../helpers"; +import { AudioWaveform } from "./AudioWaveform"; + +type Props = { + elapsedTime: number; + audioStream: MediaStream | null; +}; + +export function RecordingIndicator({ elapsedTime, audioStream }: Props) { + return ( +
+ + + {formatElapsedTime(elapsedTime)} + +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/helpers.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/helpers.ts new file mode 100644 index 0000000000..26bae8c9d9 --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/helpers.ts @@ -0,0 +1,6 @@ +export function formatElapsedTime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; +} diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts index 6fa8e7252b..a053e6080f 100644 --- a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useChatInput.ts @@ -6,7 +6,7 @@ import { useState, } from "react"; -interface UseChatInputArgs { +interface Args { onSend: (message: string) => void; disabled?: boolean; maxRows?: number; @@ -18,7 +18,7 @@ export function useChatInput({ disabled = false, maxRows = 5, inputId = "chat-input", -}: UseChatInputArgs) { +}: Args) { const [value, setValue] = useState(""); const [hasMultipleLines, setHasMultipleLines] = useState(false); diff --git a/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useVoiceRecording.ts b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useVoiceRecording.ts new file mode 100644 index 0000000000..13b625e69c --- /dev/null +++ b/autogpt_platform/frontend/src/components/contextual/Chat/components/ChatInput/useVoiceRecording.ts @@ -0,0 +1,240 @@ +import { useToast } from "@/components/molecules/Toast/use-toast"; +import React, { + KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +const MAX_RECORDING_DURATION = 2 * 60 * 1000; // 2 minutes in ms + +interface Args { + setValue: React.Dispatch>; + disabled?: boolean; + isStreaming?: boolean; + value: string; + baseHandleKeyDown: (event: KeyboardEvent) => void; +} + +export function useVoiceRecording({ + setValue, + disabled = false, + isStreaming = false, + value, + baseHandleKeyDown, +}: Args) { + const [isRecording, setIsRecording] = useState(false); + const [isTranscribing, setIsTranscribing] = useState(false); + const [error, setError] = useState(null); + const [elapsedTime, setElapsedTime] = useState(0); + + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const timerRef = useRef(null); + const startTimeRef = useRef(0); + const streamRef = useRef(null); + const isRecordingRef = useRef(false); + + const isSupported = + typeof window !== "undefined" && + !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const cleanup = useCallback(() => { + clearTimer(); + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + mediaRecorderRef.current = null; + chunksRef.current = []; + setElapsedTime(0); + }, [clearTimer]); + + const handleTranscription = useCallback( + (text: string) => { + setValue((prev) => { + const trimmedPrev = prev.trim(); + if (trimmedPrev) { + return `${trimmedPrev} ${text}`; + } + return text; + }); + }, + [setValue], + ); + + const transcribeAudio = useCallback( + async (audioBlob: Blob) => { + setIsTranscribing(true); + setError(null); + + try { + const formData = new FormData(); + formData.append("audio", audioBlob); + + const response = await fetch("/api/transcribe", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || "Transcription failed"); + } + + const data = await response.json(); + if (data.text) { + handleTranscription(data.text); + } + } catch (err) { + const message = + err instanceof Error ? err.message : "Transcription failed"; + setError(message); + console.error("Transcription error:", err); + } finally { + setIsTranscribing(false); + } + }, + [handleTranscription], + ); + + const stopRecording = useCallback(() => { + if (mediaRecorderRef.current && isRecordingRef.current) { + mediaRecorderRef.current.stop(); + isRecordingRef.current = false; + setIsRecording(false); + clearTimer(); + } + }, [clearTimer]); + + const startRecording = useCallback(async () => { + if (disabled || isRecordingRef.current || isTranscribing) return; + + setError(null); + chunksRef.current = []; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + + const mediaRecorder = new MediaRecorder(stream, { + mimeType: MediaRecorder.isTypeSupported("audio/webm") + ? "audio/webm" + : "audio/mp4", + }); + + mediaRecorderRef.current = mediaRecorder; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = async () => { + const audioBlob = new Blob(chunksRef.current, { + type: mediaRecorder.mimeType, + }); + + // Cleanup stream + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + + if (audioBlob.size > 0) { + await transcribeAudio(audioBlob); + } + }; + + mediaRecorder.start(1000); // Collect data every second + isRecordingRef.current = true; + setIsRecording(true); + startTimeRef.current = Date.now(); + + // Start elapsed time timer + timerRef.current = setInterval(() => { + const elapsed = Date.now() - startTimeRef.current; + setElapsedTime(elapsed); + + // Auto-stop at max duration + if (elapsed >= MAX_RECORDING_DURATION) { + stopRecording(); + } + }, 100); + } catch (err) { + console.error("Failed to start recording:", err); + if (err instanceof DOMException && err.name === "NotAllowedError") { + setError("Microphone permission denied"); + } else { + setError("Failed to access microphone"); + } + cleanup(); + } + }, [disabled, isTranscribing, stopRecording, transcribeAudio, cleanup]); + + const toggleRecording = useCallback(() => { + if (isRecording) { + stopRecording(); + } else { + startRecording(); + } + }, [isRecording, startRecording, stopRecording]); + + const { toast } = useToast(); + + useEffect(() => { + if (error) { + toast({ + title: "Voice recording failed", + description: error, + variant: "destructive", + }); + } + }, [error, toast]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === " " && !value.trim() && !isTranscribing) { + event.preventDefault(); + toggleRecording(); + return; + } + baseHandleKeyDown(event); + }, + [value, isTranscribing, toggleRecording, baseHandleKeyDown], + ); + + const showMicButton = isSupported && !isStreaming; + const isInputDisabled = disabled || isStreaming || isTranscribing; + + // Cleanup on unmount + useEffect(() => { + return () => { + cleanup(); + }; + }, [cleanup]); + + return { + isRecording, + isTranscribing, + error, + elapsedTime, + startRecording, + stopRecording, + toggleRecording, + isSupported, + handleKeyDown, + showMicButton, + isInputDisabled, + audioStream: streamRef.current, + }; +} From 4cd5da678d73a4a9af8876de6cc763ad05d0c719 Mon Sep 17 00:00:00 2001 From: Reinier van der Leer Date: Thu, 29 Jan 2026 18:33:02 +0100 Subject: [PATCH 33/36] refactor(claude): Split `autogpt_platform/CLAUDE.md` into project-specific files (#11788) Split `autogpt_platform/CLAUDE.md` into project-specific files, to make the scope of the instructions clearer. Also, some minor improvements: - Change references to other Markdown files to @file/path.md syntax that Claude recognizes - Update ambiguous/incorrect/outdated instructions - Remove trailing slashes - Fix broken file path references in other docs (including comments) --- .github/copilot-instructions.md | 6 +- .gitignore | 1 + autogpt_platform/CLAUDE.md | 259 +----------------- autogpt_platform/backend/CLAUDE.md | 170 ++++++++++++ autogpt_platform/backend/TESTING.md | 2 +- .../backend/api/features/builder/routes.py | 2 +- autogpt_platform/frontend/CLAUDE.md | 76 +++++ .../src/lib/autogpt-server-api/types.ts | 10 +- .../contributing/oauth-integration-flow.md | 2 +- docs/platform/ollama.md | 2 +- 10 files changed, 274 insertions(+), 256 deletions(-) create mode 100644 autogpt_platform/backend/CLAUDE.md create mode 100644 autogpt_platform/frontend/CLAUDE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 870e6b4b0a..3c72eaae18 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -160,7 +160,7 @@ pnpm storybook # Start component development server **Backend Entry Points:** -- `backend/backend/server/server.py` - FastAPI application setup +- `backend/backend/api/rest_api.py` - FastAPI application setup - `backend/backend/data/` - Database models and user management - `backend/blocks/` - Agent execution blocks and logic @@ -219,7 +219,7 @@ Agents are built using a visual block-based system where each block performs a s ### API Development -1. Update routes in `/backend/backend/server/routers/` +1. Update routes in `/backend/backend/api/features/` 2. Add/update Pydantic models in same directory 3. Write tests alongside route files 4. For `data/*.py` changes, validate user ID checks @@ -285,7 +285,7 @@ Agents are built using a visual block-based system where each block performs a s ### Security Guidelines -**Cache Protection Middleware** (`/backend/backend/server/middleware/security.py`): +**Cache Protection Middleware** (`/backend/backend/api/middleware/security.py`): - Default: Disables caching for ALL endpoints with `Cache-Control: no-store, no-cache, must-revalidate, private` - Uses allow list approach for cacheable paths (static assets, health checks, public pages) diff --git a/.gitignore b/.gitignore index dfce8ba810..1a2291b516 100644 --- a/.gitignore +++ b/.gitignore @@ -178,4 +178,5 @@ autogpt_platform/backend/settings.py *.ign.* .test-contents .claude/settings.local.json +CLAUDE.local.md /autogpt_platform/backend/logs diff --git a/autogpt_platform/CLAUDE.md b/autogpt_platform/CLAUDE.md index a5a588b667..62adbdaefa 100644 --- a/autogpt_platform/CLAUDE.md +++ b/autogpt_platform/CLAUDE.md @@ -6,141 +6,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co AutoGPT Platform is a monorepo containing: -- **Backend** (`/backend`): Python FastAPI server with async support -- **Frontend** (`/frontend`): Next.js React application -- **Shared Libraries** (`/autogpt_libs`): Common Python utilities +- **Backend** (`backend`): Python FastAPI server with async support +- **Frontend** (`frontend`): Next.js React application +- **Shared Libraries** (`autogpt_libs`): Common Python utilities -## Essential Commands +## Component Documentation -### Backend Development +- **Backend**: See @backend/CLAUDE.md for backend-specific commands, architecture, and development tasks +- **Frontend**: See @frontend/CLAUDE.md for frontend-specific commands, architecture, and development patterns -```bash -# Install dependencies -cd backend && poetry install - -# Run database migrations -poetry run prisma migrate dev - -# Start all services (database, redis, rabbitmq, clamav) -docker compose up -d - -# Run the backend server -poetry run serve - -# Run tests -poetry run test - -# Run specific test -poetry run pytest path/to/test_file.py::test_function_name - -# Run block tests (tests that validate all blocks work correctly) -poetry run pytest backend/blocks/test/test_block.py -xvs - -# Run tests for a specific block (e.g., GetCurrentTimeBlock) -poetry run pytest 'backend/blocks/test/test_block.py::test_available_blocks[GetCurrentTimeBlock]' -xvs - -# Lint and format -# prefer format if you want to just "fix" it and only get the errors that can't be autofixed -poetry run format # Black + isort -poetry run lint # ruff -``` - -More details can be found in TESTING.md - -#### Creating/Updating Snapshots - -When you first write a test or when the expected output changes: - -```bash -poetry run pytest path/to/test.py --snapshot-update -``` - -āš ļø **Important**: Always review snapshot changes before committing! Use `git diff` to verify the changes are expected. - -### Frontend Development - -```bash -# Install dependencies -cd frontend && pnpm i - -# Generate API client from OpenAPI spec -pnpm generate:api - -# Start development server -pnpm dev - -# Run E2E tests -pnpm test - -# Run Storybook for component development -pnpm storybook - -# Build production -pnpm build - -# Format and lint -pnpm format - -# Type checking -pnpm types -``` - -## Architecture Overview - -### Backend Architecture - -- **API Layer**: FastAPI with REST and WebSocket endpoints -- **Database**: PostgreSQL with Prisma ORM, includes pgvector for embeddings -- **Queue System**: RabbitMQ for async task processing -- **Execution Engine**: Separate executor service processes agent workflows -- **Authentication**: JWT-based with Supabase integration -- **Security**: Cache protection middleware prevents sensitive data caching in browsers/proxies - -### Frontend Architecture - -- **Framework**: Next.js 15 App Router (client-first approach) -- **Data Fetching**: Type-safe generated API hooks via Orval + React Query -- **State Management**: React Query for server state, co-located UI state in components/hooks -- **Component Structure**: Separate render logic (`.tsx`) from business logic (`use*.ts` hooks) -- **Workflow Builder**: Visual graph editor using @xyflow/react -- **UI Components**: shadcn/ui (Radix UI primitives) with Tailwind CSS styling -- **Icons**: Phosphor Icons only -- **Feature Flags**: LaunchDarkly integration -- **Error Handling**: ErrorCard for render errors, toast for mutations, Sentry for exceptions -- **Testing**: Playwright for E2E, Storybook for component development - -### Key Concepts +## Key Concepts 1. **Agent Graphs**: Workflow definitions stored as JSON, executed by the backend -2. **Blocks**: Reusable components in `/backend/blocks/` that perform specific tasks +2. **Blocks**: Reusable components in `backend/backend/blocks/` that perform specific tasks 3. **Integrations**: OAuth and API connections stored per user 4. **Store**: Marketplace for sharing agent templates 5. **Virus Scanning**: ClamAV integration for file upload security -### Testing Approach - -- Backend uses pytest with snapshot testing for API responses -- Test files are colocated with source files (`*_test.py`) -- Frontend uses Playwright for E2E tests -- Component testing via Storybook - -### Database Schema - -Key models (defined in `/backend/schema.prisma`): - -- `User`: Authentication and profile data -- `AgentGraph`: Workflow definitions with version control -- `AgentGraphExecution`: Execution history and results -- `AgentNode`: Individual nodes in a workflow -- `StoreListing`: Marketplace listings for sharing agents - ### Environment Configuration #### Configuration Files -- **Backend**: `/backend/.env.default` (defaults) → `/backend/.env` (user overrides) -- **Frontend**: `/frontend/.env.default` (defaults) → `/frontend/.env` (user overrides) -- **Platform**: `/.env.default` (Supabase/shared defaults) → `/.env` (user overrides) +- **Backend**: `backend/.env.default` (defaults) → `backend/.env` (user overrides) +- **Frontend**: `frontend/.env.default` (defaults) → `frontend/.env` (user overrides) +- **Platform**: `.env.default` (Supabase/shared defaults) → `.env` (user overrides) #### Docker Environment Loading Order @@ -156,130 +45,12 @@ Key models (defined in `/backend/schema.prisma`): - Backend/Frontend services use YAML anchors for consistent configuration - Supabase services (`db/docker/docker-compose.yml`) follow the same pattern -### Common Development Tasks - -**Adding a new block:** - -Follow the comprehensive [Block SDK Guide](../../../docs/content/platform/block-sdk-guide.md) which covers: - -- Provider configuration with `ProviderBuilder` -- Block schema definition -- Authentication (API keys, OAuth, webhooks) -- Testing and validation -- File organization - -Quick steps: - -1. Create new file in `/backend/backend/blocks/` -2. Configure provider using `ProviderBuilder` in `_config.py` -3. Inherit from `Block` base class -4. Define input/output schemas using `BlockSchema` -5. Implement async `run` method -6. Generate unique block ID using `uuid.uuid4()` -7. Test with `poetry run pytest backend/blocks/test/test_block.py` - -Note: when making many new blocks analyze the interfaces for each of these blocks and picture if they would go well together in a graph based editor or would they struggle to connect productively? -ex: do the inputs and outputs tie well together? - -If you get any pushback or hit complex block conditions check the new_blocks guide in the docs. - -**Handling files in blocks with `store_media_file()`:** - -When blocks need to work with files (images, videos, documents), use `store_media_file()` from `backend.util.file`. The `return_format` parameter determines what you get back: - -| Format | Use When | Returns | -|--------|----------|---------| -| `"for_local_processing"` | Processing with local tools (ffmpeg, MoviePy, PIL) | Local file path (e.g., `"image.png"`) | -| `"for_external_api"` | Sending content to external APIs (Replicate, OpenAI) | Data URI (e.g., `"data:image/png;base64,..."`) | -| `"for_block_output"` | Returning output from your block | Smart: `workspace://` in CoPilot, data URI in graphs | - -**Examples:** -```python -# INPUT: Need to process file locally with ffmpeg -local_path = await store_media_file( - file=input_data.video, - execution_context=execution_context, - return_format="for_local_processing", -) -# local_path = "video.mp4" - use with Path/ffmpeg/etc - -# INPUT: Need to send to external API like Replicate -image_b64 = await store_media_file( - file=input_data.image, - execution_context=execution_context, - return_format="for_external_api", -) -# image_b64 = "data:image/png;base64,iVBORw0..." - send to API - -# OUTPUT: Returning result from block -result_url = await store_media_file( - file=generated_image_url, - execution_context=execution_context, - return_format="for_block_output", -) -yield "image_url", result_url -# In CoPilot: result_url = "workspace://abc123" -# In graphs: result_url = "data:image/png;base64,..." -``` - -**Key points:** -- `for_block_output` is the ONLY format that auto-adapts to execution context -- Always use `for_block_output` for block outputs unless you have a specific reason not to -- Never hardcode workspace checks - let `for_block_output` handle it - -**Modifying the API:** - -1. Update route in `/backend/backend/server/routers/` -2. Add/update Pydantic models in same directory -3. Write tests alongside the route file -4. Run `poetry run test` to verify - -### Frontend guidelines: - -See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference: - -1. **Pages**: Create in `src/app/(platform)/feature-name/page.tsx` - - Add `usePageName.ts` hook for logic - - Put sub-components in local `components/` folder -2. **Components**: Structure as `ComponentName/ComponentName.tsx` + `useComponentName.ts` + `helpers.ts` - - Use design system components from `src/components/` (atoms, molecules, organisms) - - Never use `src/components/__legacy__/*` -3. **Data fetching**: Use generated API hooks from `@/app/api/__generated__/endpoints/` - - Regenerate with `pnpm generate:api` - - Pattern: `use{Method}{Version}{OperationName}` -4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only -5. **Testing**: Add Storybook stories for new components, Playwright for E2E -6. **Code conventions**: Function declarations (not arrow functions) for components/handlers - -- Component props should be `interface Props { ... }` (not exported) unless the interface needs to be used outside the component -- Separate render logic from business logic (component.tsx + useComponent.ts + helpers.ts) -- Colocate state when possible and avoid creating large components, use sub-components ( local `/components` folder next to the parent component ) when sensible -- Avoid large hooks, abstract logic into `helpers.ts` files when sensible -- Use function declarations for components, arrow functions only for callbacks -- No barrel files or `index.ts` re-exports -- Do not use `useCallback` or `useMemo` unless asked to optimise a given function -- Avoid comments at all times unless the code is very complex -- Do not type hook returns, let Typescript infer as much as possible -- Never type with `any`, if not types available use `unknown` - -### Security Implementation - -**Cache Protection Middleware:** - -- Located in `/backend/backend/server/middleware/security.py` -- Default behavior: Disables caching for ALL endpoints with `Cache-Control: no-store, no-cache, must-revalidate, private` -- Uses an allow list approach - only explicitly permitted paths can be cached -- Cacheable paths include: static assets (`/static/*`, `/_next/static/*`), health checks, public store pages, documentation -- Prevents sensitive data (auth tokens, API keys, user data) from being cached by browsers/proxies -- To allow caching for a new endpoint, add it to `CACHEABLE_PATHS` in the middleware -- Applied to both main API server and external API applications - ### Creating Pull Requests -- Create the PR aginst the `dev` branch of the repository. -- Ensure the branch name is descriptive (e.g., `feature/add-new-block`)/ -- Use conventional commit messages (see below)/ -- Fill out the .github/PULL_REQUEST_TEMPLATE.md template as the PR description/ +- Create the PR against the `dev` branch of the repository. +- Ensure the branch name is descriptive (e.g., `feature/add-new-block`) +- Use conventional commit messages (see below) +- Fill out the .github/PULL_REQUEST_TEMPLATE.md template as the PR description - Run the github pre-commit hooks to ensure code quality. ### Reviewing/Revising Pull Requests diff --git a/autogpt_platform/backend/CLAUDE.md b/autogpt_platform/backend/CLAUDE.md new file mode 100644 index 0000000000..53d52bb4d3 --- /dev/null +++ b/autogpt_platform/backend/CLAUDE.md @@ -0,0 +1,170 @@ +# CLAUDE.md - Backend + +This file provides guidance to Claude Code when working with the backend. + +## Essential Commands + +To run something with Python package dependencies you MUST use `poetry run ...`. + +```bash +# Install dependencies +poetry install + +# Run database migrations +poetry run prisma migrate dev + +# Start all services (database, redis, rabbitmq, clamav) +docker compose up -d + +# Run the backend as a whole +poetry run app + +# Run tests +poetry run test + +# Run specific test +poetry run pytest path/to/test_file.py::test_function_name + +# Run block tests (tests that validate all blocks work correctly) +poetry run pytest backend/blocks/test/test_block.py -xvs + +# Run tests for a specific block (e.g., GetCurrentTimeBlock) +poetry run pytest 'backend/blocks/test/test_block.py::test_available_blocks[GetCurrentTimeBlock]' -xvs + +# Lint and format +# prefer format if you want to just "fix" it and only get the errors that can't be autofixed +poetry run format # Black + isort +poetry run lint # ruff +``` + +More details can be found in @TESTING.md + +### Creating/Updating Snapshots + +When you first write a test or when the expected output changes: + +```bash +poetry run pytest path/to/test.py --snapshot-update +``` + +āš ļø **Important**: Always review snapshot changes before committing! Use `git diff` to verify the changes are expected. + +## Architecture + +- **API Layer**: FastAPI with REST and WebSocket endpoints +- **Database**: PostgreSQL with Prisma ORM, includes pgvector for embeddings +- **Queue System**: RabbitMQ for async task processing +- **Execution Engine**: Separate executor service processes agent workflows +- **Authentication**: JWT-based with Supabase integration +- **Security**: Cache protection middleware prevents sensitive data caching in browsers/proxies + +## Testing Approach + +- Uses pytest with snapshot testing for API responses +- Test files are colocated with source files (`*_test.py`) + +## Database Schema + +Key models (defined in `schema.prisma`): + +- `User`: Authentication and profile data +- `AgentGraph`: Workflow definitions with version control +- `AgentGraphExecution`: Execution history and results +- `AgentNode`: Individual nodes in a workflow +- `StoreListing`: Marketplace listings for sharing agents + +## Environment Configuration + +- **Backend**: `.env.default` (defaults) → `.env` (user overrides) + +## Common Development Tasks + +### Adding a new block + +Follow the comprehensive [Block SDK Guide](@../../docs/content/platform/block-sdk-guide.md) which covers: + +- Provider configuration with `ProviderBuilder` +- Block schema definition +- Authentication (API keys, OAuth, webhooks) +- Testing and validation +- File organization + +Quick steps: + +1. Create new file in `backend/blocks/` +2. Configure provider using `ProviderBuilder` in `_config.py` +3. Inherit from `Block` base class +4. Define input/output schemas using `BlockSchema` +5. Implement async `run` method +6. Generate unique block ID using `uuid.uuid4()` +7. Test with `poetry run pytest backend/blocks/test/test_block.py` + +Note: when making many new blocks analyze the interfaces for each of these blocks and picture if they would go well together in a graph-based editor or would they struggle to connect productively? +ex: do the inputs and outputs tie well together? + +If you get any pushback or hit complex block conditions check the new_blocks guide in the docs. + +#### Handling files in blocks with `store_media_file()` + +When blocks need to work with files (images, videos, documents), use `store_media_file()` from `backend.util.file`. The `return_format` parameter determines what you get back: + +| Format | Use When | Returns | +|--------|----------|---------| +| `"for_local_processing"` | Processing with local tools (ffmpeg, MoviePy, PIL) | Local file path (e.g., `"image.png"`) | +| `"for_external_api"` | Sending content to external APIs (Replicate, OpenAI) | Data URI (e.g., `"data:image/png;base64,..."`) | +| `"for_block_output"` | Returning output from your block | Smart: `workspace://` in CoPilot, data URI in graphs | + +**Examples:** + +```python +# INPUT: Need to process file locally with ffmpeg +local_path = await store_media_file( + file=input_data.video, + execution_context=execution_context, + return_format="for_local_processing", +) +# local_path = "video.mp4" - use with Path/ffmpeg/etc + +# INPUT: Need to send to external API like Replicate +image_b64 = await store_media_file( + file=input_data.image, + execution_context=execution_context, + return_format="for_external_api", +) +# image_b64 = "data:image/png;base64,iVBORw0..." - send to API + +# OUTPUT: Returning result from block +result_url = await store_media_file( + file=generated_image_url, + execution_context=execution_context, + return_format="for_block_output", +) +yield "image_url", result_url +# In CoPilot: result_url = "workspace://abc123" +# In graphs: result_url = "data:image/png;base64,..." +``` + +**Key points:** + +- `for_block_output` is the ONLY format that auto-adapts to execution context +- Always use `for_block_output` for block outputs unless you have a specific reason not to +- Never hardcode workspace checks - let `for_block_output` handle it + +### Modifying the API + +1. Update route in `backend/api/features/` +2. Add/update Pydantic models in same directory +3. Write tests alongside the route file +4. Run `poetry run test` to verify + +## Security Implementation + +### Cache Protection Middleware + +- Located in `backend/api/middleware/security.py` +- Default behavior: Disables caching for ALL endpoints with `Cache-Control: no-store, no-cache, must-revalidate, private` +- Uses an allow list approach - only explicitly permitted paths can be cached +- Cacheable paths include: static assets (`static/*`, `_next/static/*`), health checks, public store pages, documentation +- Prevents sensitive data (auth tokens, API keys, user data) from being cached by browsers/proxies +- To allow caching for a new endpoint, add it to `CACHEABLE_PATHS` in the middleware +- Applied to both main API server and external API applications diff --git a/autogpt_platform/backend/TESTING.md b/autogpt_platform/backend/TESTING.md index a3a5db68ef..2e09144485 100644 --- a/autogpt_platform/backend/TESTING.md +++ b/autogpt_platform/backend/TESTING.md @@ -138,7 +138,7 @@ If the test doesn't need the `user_id` specifically, mocking is not necessary as #### Using Global Auth Fixtures -Two global auth fixtures are provided by `backend/server/conftest.py`: +Two global auth fixtures are provided by `backend/api/conftest.py`: - `mock_jwt_user` - Regular user with `test_user_id` ("test-user-id") - `mock_jwt_admin` - Admin user with `admin_user_id` ("admin-user-id") diff --git a/autogpt_platform/backend/backend/api/features/builder/routes.py b/autogpt_platform/backend/backend/api/features/builder/routes.py index 7fe9cab189..15b922178d 100644 --- a/autogpt_platform/backend/backend/api/features/builder/routes.py +++ b/autogpt_platform/backend/backend/api/features/builder/routes.py @@ -17,7 +17,7 @@ router = fastapi.APIRouter( ) -# Taken from backend/server/v2/store/db.py +# Taken from backend/api/features/store/db.py def sanitize_query(query: str | None) -> str | None: if query is None: return query diff --git a/autogpt_platform/frontend/CLAUDE.md b/autogpt_platform/frontend/CLAUDE.md new file mode 100644 index 0000000000..b58f1ad6aa --- /dev/null +++ b/autogpt_platform/frontend/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md - Frontend + +This file provides guidance to Claude Code when working with the frontend. + +## Essential Commands + +```bash +# Install dependencies +pnpm i + +# Generate API client from OpenAPI spec +pnpm generate:api + +# Start development server +pnpm dev + +# Run E2E tests +pnpm test + +# Run Storybook for component development +pnpm storybook + +# Build production +pnpm build + +# Format and lint +pnpm format + +# Type checking +pnpm types +``` + +### Code Style + +- Fully capitalize acronyms in symbols, e.g. `graphID`, `useBackendAPI` +- Use function declarations (not arrow functions) for components/handlers + +## Architecture + +- **Framework**: Next.js 15 App Router (client-first approach) +- **Data Fetching**: Type-safe generated API hooks via Orval + React Query +- **State Management**: React Query for server state, co-located UI state in components/hooks +- **Component Structure**: Separate render logic (`.tsx`) from business logic (`use*.ts` hooks) +- **Workflow Builder**: Visual graph editor using @xyflow/react +- **UI Components**: shadcn/ui (Radix UI primitives) with Tailwind CSS styling +- **Icons**: Phosphor Icons only +- **Feature Flags**: LaunchDarkly integration +- **Error Handling**: ErrorCard for render errors, toast for mutations, Sentry for exceptions +- **Testing**: Playwright for E2E, Storybook for component development + +## Environment Configuration + +`.env.default` (defaults) → `.env` (user overrides) + +## Feature Development + +See @CONTRIBUTING.md for complete patterns. Quick reference: + +1. **Pages**: Create in `src/app/(platform)/feature-name/page.tsx` + - Extract component logic into custom hooks grouped by concern, not by component. Each hook should represent a cohesive domain of functionality (e.g., useSearch, useFilters, usePagination) rather than bundling all state into one useComponentState hook. + - Put each hook in its own `.ts` file + - Put sub-components in local `components/` folder + - Component props should be `type Props = { ... }` (not exported) unless it needs to be used outside the component +2. **Components**: Structure as `ComponentName/ComponentName.tsx` + `useComponentName.ts` + `helpers.ts` + - Use design system components from `src/components/` (atoms, molecules, organisms) + - Never use `src/components/__legacy__/*` +3. **Data fetching**: Use generated API hooks from `@/app/api/__generated__/endpoints/` + - Regenerate with `pnpm generate:api` + - Pattern: `use{Method}{Version}{OperationName}` +4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only +5. **Testing**: Add Storybook stories for new components, Playwright for E2E +6. **Code conventions**: + - Use function declarations (not arrow functions) for components/handlers + - Do not use `useCallback` or `useMemo` unless asked to optimise a given function + - Do not type hook returns, let Typescript infer as much as possible + - Never type with `any` unless a variable/attribute can ACTUALLY be of any type diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index 2d583d2062..74855f5e28 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -516,7 +516,7 @@ export type GraphValidationErrorResponse = { /* *** LIBRARY *** */ -/* Mirror of backend/server/v2/library/model.py:LibraryAgent */ +/* Mirror of backend/api/features/library/model.py:LibraryAgent */ export type LibraryAgent = { id: LibraryAgentID; graph_id: GraphID; @@ -616,7 +616,7 @@ export enum LibraryAgentSortEnum { /* *** CREDENTIALS *** */ -/* Mirror of backend/server/integrations/router.py:CredentialsMetaResponse */ +/* Mirror of backend/api/features/integrations/router.py:CredentialsMetaResponse */ export type CredentialsMetaResponse = { id: string; provider: CredentialsProviderName; @@ -628,13 +628,13 @@ export type CredentialsMetaResponse = { is_system?: boolean; }; -/* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */ +/* Mirror of backend/api/features/integrations/router.py:CredentialsDeletionResponse */ export type CredentialsDeleteResponse = { deleted: true; revoked: boolean | null; }; -/* Mirror of backend/server/integrations/router.py:CredentialsDeletionNeedsConfirmationResponse */ +/* Mirror of backend/api/features/integrations/router.py:CredentialsDeletionNeedsConfirmationResponse */ export type CredentialsDeleteNeedConfirmationResponse = { deleted: false; need_confirmation: true; @@ -888,7 +888,7 @@ export type Schedule = { export type ScheduleID = Brand; -/* Mirror of backend/server/routers/v1.py:ScheduleCreationRequest */ +/* Mirror of backend/api/features/v1.py:ScheduleCreationRequest */ export type ScheduleCreatable = { graph_id: GraphID; graph_version: number; diff --git a/docs/platform/contributing/oauth-integration-flow.md b/docs/platform/contributing/oauth-integration-flow.md index dbc7a54be5..f6c3f7fd17 100644 --- a/docs/platform/contributing/oauth-integration-flow.md +++ b/docs/platform/contributing/oauth-integration-flow.md @@ -25,7 +25,7 @@ This document focuses on the **API Integration OAuth flow** used for connecting ### 2. Backend API Trust Boundary - **Location**: Server-side FastAPI application - **Components**: - - Integration router (`/backend/backend/server/integrations/router.py`) + - Integration router (`/backend/backend/api/features/integrations/router.py`) - OAuth handlers (`/backend/backend/integrations/oauth/`) - Credentials store (`/backend/backend/integrations/credentials_store.py`) - **Trust Level**: Trusted - server-controlled environment diff --git a/docs/platform/ollama.md b/docs/platform/ollama.md index 392bfabfe8..ecab9b8ae1 100644 --- a/docs/platform/ollama.md +++ b/docs/platform/ollama.md @@ -246,7 +246,7 @@ If you encounter any issues, verify that: ```bash ollama pull llama3.2 ``` -- If using a custom model, ensure it's added to the model list in `backend/server/model.py` +- If using a custom model, ensure it's added to the model list in `backend/api/model.py` #### Docker Issues - Ensure Docker daemon is running: From b2eb4831bd3d309b04242a558c905b2ae4dc5aee Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Thu, 29 Jan 2026 13:53:40 -0600 Subject: [PATCH 34/36] feat(chat): improve agent generator error propagation (#11884) ## Summary - Add helper functions in `service.py` to create standardized error responses with `error_type` classification - Update service functions to return error dicts instead of `None`, preserving error details from the Agent Generator microservice - Update `core.py` to pass through error responses properly - Update `create_agent.py` to handle error responses with user-friendly messages based on error type ## Error Types Now Propagated | Error Type | Description | User Message | |------------|-------------|--------------| | `llm_parse_error` | LLM returned unparseable response | "The AI had trouble understanding this request" | | `llm_timeout` / `timeout` | Request timed out | "The request took too long" | | `llm_rate_limit` / `rate_limit` | Rate limited | "The service is currently busy" | | `validation_error` | Agent validation failed | "The generated agent failed validation" | | `connection_error` | Could not connect to Agent Generator | Generic error message | | `http_error` | HTTP error from Agent Generator | Generic error message | | `unknown` | Unclassified error | Generic error message | ## Motivation This enables better debugging for issues like SECRT-1817 where decomposition failed due to transient LLM errors but the root cause was unclear in the logs. Now: 1. Error details from the Agent Generator microservice are preserved 2. Users get more helpful error messages based on error type 3. Debugging is easier with `error_type` in response details ## Related PR - Agent Generator side: https://github.com/Significant-Gravitas/AutoGPT-Agent-Generator/pull/102 ## Test Plan - [ ] Test decomposition with various error scenarios (timeout, parse error) - [ ] Verify user-friendly messages are shown based on error type - [ ] Check that error details are logged properly --- .../chat/tools/agent_generator/__init__.py | 3 + .../chat/tools/agent_generator/core.py | 10 +- .../chat/tools/agent_generator/errors.py | 43 +++++ .../chat/tools/agent_generator/service.py | 163 ++++++++++++++---- .../api/features/chat/tools/create_agent.py | 50 +++++- .../api/features/chat/tools/edit_agent.py | 23 +++ .../test/agent_generator/test_service.py | 25 ++- 7 files changed, 274 insertions(+), 43 deletions(-) create mode 100644 autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/errors.py diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/__init__.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/__init__.py index 392f642c41..499025b7dc 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/__init__.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/__init__.py @@ -9,6 +9,7 @@ from .core import ( json_to_graph, save_agent_to_library, ) +from .errors import get_user_message_for_error from .service import health_check as check_external_service_health from .service import is_external_service_configured @@ -25,4 +26,6 @@ __all__ = [ # Service "is_external_service_configured", "check_external_service_health", + # Error handling + "get_user_message_for_error", ] diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/core.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/core.py index fc15587110..d56e33cbb0 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/core.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/core.py @@ -64,7 +64,7 @@ async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None: instructions: Structured instructions from decompose_goal Returns: - Agent JSON dict or None on error + Agent JSON dict, error dict {"type": "error", ...}, or None on error Raises: AgentGeneratorNotConfiguredError: If the external service is not configured. @@ -73,7 +73,10 @@ async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None: logger.info("Calling external Agent Generator service for generate_agent") result = await generate_agent_external(instructions) if result: - # Ensure required fields + # Check if it's an error response - pass through as-is + if isinstance(result, dict) and result.get("type") == "error": + return result + # Ensure required fields for successful agent generation if "id" not in result: result["id"] = str(uuid.uuid4()) if "version" not in result: @@ -267,7 +270,8 @@ async def generate_agent_patch( current_agent: Current agent JSON Returns: - Updated agent JSON, clarifying questions dict, or None on error + Updated agent JSON, clarifying questions dict {"type": "clarifying_questions", ...}, + error dict {"type": "error", ...}, or None on unexpected error Raises: AgentGeneratorNotConfiguredError: If the external service is not configured. diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/errors.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/errors.py new file mode 100644 index 0000000000..bf71a95df9 --- /dev/null +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/errors.py @@ -0,0 +1,43 @@ +"""Error handling utilities for agent generator.""" + + +def get_user_message_for_error( + error_type: str, + operation: str = "process the request", + llm_parse_message: str | None = None, + validation_message: str | None = None, +) -> str: + """Get a user-friendly error message based on error type. + + This function maps internal error types to user-friendly messages, + providing a consistent experience across different agent operations. + + Args: + error_type: The error type from the external service + (e.g., "llm_parse_error", "timeout", "rate_limit") + operation: Description of what operation failed, used in the default + message (e.g., "analyze the goal", "generate the agent") + llm_parse_message: Custom message for llm_parse_error type + validation_message: Custom message for validation_error type + + Returns: + User-friendly error message suitable for display to the user + """ + if error_type == "llm_parse_error": + return ( + llm_parse_message + or "The AI had trouble processing this request. Please try again." + ) + elif error_type == "validation_error": + return ( + validation_message + or "The request failed validation. Please try rephrasing." + ) + elif error_type == "patch_error": + return "Failed to apply the changes. Please try a different approach." + elif error_type in ("timeout", "llm_timeout"): + return "The request took too long. Please try again." + elif error_type in ("rate_limit", "llm_rate_limit"): + return "The service is currently busy. Please try again in a moment." + else: + return f"Failed to {operation}. Please try again." diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py index a4d2f1af15..1df1faaaef 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/agent_generator/service.py @@ -14,6 +14,70 @@ from backend.util.settings import Settings logger = logging.getLogger(__name__) + +def _create_error_response( + error_message: str, + error_type: str = "unknown", + details: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Create a standardized error response dict. + + Args: + error_message: Human-readable error message + error_type: Machine-readable error type + details: Optional additional error details + + Returns: + Error dict with type="error" and error details + """ + response: dict[str, Any] = { + "type": "error", + "error": error_message, + "error_type": error_type, + } + if details: + response["details"] = details + return response + + +def _classify_http_error(e: httpx.HTTPStatusError) -> tuple[str, str]: + """Classify an HTTP error into error_type and message. + + Args: + e: The HTTP status error + + Returns: + Tuple of (error_type, error_message) + """ + status = e.response.status_code + if status == 429: + return "rate_limit", f"Agent Generator rate limited: {e}" + elif status == 503: + return "service_unavailable", f"Agent Generator unavailable: {e}" + elif status == 504 or status == 408: + return "timeout", f"Agent Generator timed out: {e}" + else: + return "http_error", f"HTTP error calling Agent Generator: {e}" + + +def _classify_request_error(e: httpx.RequestError) -> tuple[str, str]: + """Classify a request error into error_type and message. + + Args: + e: The request error + + Returns: + Tuple of (error_type, error_message) + """ + error_str = str(e).lower() + if "timeout" in error_str or "timed out" in error_str: + return "timeout", f"Agent Generator request timed out: {e}" + elif "connect" in error_str: + return "connection_error", f"Could not connect to Agent Generator: {e}" + else: + return "request_error", f"Request error calling Agent Generator: {e}" + + _client: httpx.AsyncClient | None = None _settings: Settings | None = None @@ -67,7 +131,8 @@ async def decompose_goal_external( - {"type": "instructions", "steps": [...]} - {"type": "unachievable_goal", ...} - {"type": "vague_goal", ...} - Or None on error + - {"type": "error", "error": "...", "error_type": "..."} on error + Or None on unexpected error """ client = _get_client() @@ -83,8 +148,13 @@ async def decompose_goal_external( data = response.json() if not data.get("success"): - logger.error(f"External service returned error: {data.get('error')}") - return None + error_msg = data.get("error", "Unknown error from Agent Generator") + error_type = data.get("error_type", "unknown") + logger.error( + f"Agent Generator decomposition failed: {error_msg} " + f"(type: {error_type})" + ) + return _create_error_response(error_msg, error_type) # Map the response to the expected format response_type = data.get("type") @@ -106,25 +176,37 @@ async def decompose_goal_external( "type": "vague_goal", "suggested_goal": data.get("suggested_goal"), } + elif response_type == "error": + # Pass through error from the service + return _create_error_response( + data.get("error", "Unknown error"), + data.get("error_type", "unknown"), + ) else: logger.error( f"Unknown response type from external service: {response_type}" ) - return None + return _create_error_response( + f"Unknown response type from Agent Generator: {response_type}", + "invalid_response", + ) except httpx.HTTPStatusError as e: - logger.error(f"HTTP error calling external agent generator: {e}") - return None + error_type, error_msg = _classify_http_error(e) + logger.error(error_msg) + return _create_error_response(error_msg, error_type) except httpx.RequestError as e: - logger.error(f"Request error calling external agent generator: {e}") - return None + error_type, error_msg = _classify_request_error(e) + logger.error(error_msg) + return _create_error_response(error_msg, error_type) except Exception as e: - logger.error(f"Unexpected error calling external agent generator: {e}") - return None + error_msg = f"Unexpected error calling Agent Generator: {e}" + logger.error(error_msg) + return _create_error_response(error_msg, "unexpected_error") async def generate_agent_external( - instructions: dict[str, Any] + instructions: dict[str, Any], ) -> dict[str, Any] | None: """Call the external service to generate an agent from instructions. @@ -132,7 +214,7 @@ async def generate_agent_external( instructions: Structured instructions from decompose_goal Returns: - Agent JSON dict or None on error + Agent JSON dict on success, or error dict {"type": "error", ...} on error """ client = _get_client() @@ -144,20 +226,28 @@ async def generate_agent_external( data = response.json() if not data.get("success"): - logger.error(f"External service returned error: {data.get('error')}") - return None + error_msg = data.get("error", "Unknown error from Agent Generator") + error_type = data.get("error_type", "unknown") + logger.error( + f"Agent Generator generation failed: {error_msg} " + f"(type: {error_type})" + ) + return _create_error_response(error_msg, error_type) return data.get("agent_json") except httpx.HTTPStatusError as e: - logger.error(f"HTTP error calling external agent generator: {e}") - return None + error_type, error_msg = _classify_http_error(e) + logger.error(error_msg) + return _create_error_response(error_msg, error_type) except httpx.RequestError as e: - logger.error(f"Request error calling external agent generator: {e}") - return None + error_type, error_msg = _classify_request_error(e) + logger.error(error_msg) + return _create_error_response(error_msg, error_type) except Exception as e: - logger.error(f"Unexpected error calling external agent generator: {e}") - return None + error_msg = f"Unexpected error calling Agent Generator: {e}" + logger.error(error_msg) + return _create_error_response(error_msg, "unexpected_error") async def generate_agent_patch_external( @@ -170,7 +260,7 @@ async def generate_agent_patch_external( current_agent: Current agent JSON Returns: - Updated agent JSON, clarifying questions dict, or None on error + Updated agent JSON, clarifying questions dict, or error dict on error """ client = _get_client() @@ -186,8 +276,13 @@ async def generate_agent_patch_external( data = response.json() if not data.get("success"): - logger.error(f"External service returned error: {data.get('error')}") - return None + error_msg = data.get("error", "Unknown error from Agent Generator") + error_type = data.get("error_type", "unknown") + logger.error( + f"Agent Generator patch generation failed: {error_msg} " + f"(type: {error_type})" + ) + return _create_error_response(error_msg, error_type) # Check if it's clarifying questions if data.get("type") == "clarifying_questions": @@ -196,18 +291,28 @@ async def generate_agent_patch_external( "questions": data.get("questions", []), } + # Check if it's an error passed through + if data.get("type") == "error": + return _create_error_response( + data.get("error", "Unknown error"), + data.get("error_type", "unknown"), + ) + # Otherwise return the updated agent JSON return data.get("agent_json") except httpx.HTTPStatusError as e: - logger.error(f"HTTP error calling external agent generator: {e}") - return None + error_type, error_msg = _classify_http_error(e) + logger.error(error_msg) + return _create_error_response(error_msg, error_type) except httpx.RequestError as e: - logger.error(f"Request error calling external agent generator: {e}") - return None + error_type, error_msg = _classify_request_error(e) + logger.error(error_msg) + return _create_error_response(error_msg, error_type) except Exception as e: - logger.error(f"Unexpected error calling external agent generator: {e}") - return None + error_msg = f"Unexpected error calling Agent Generator: {e}" + logger.error(error_msg) + return _create_error_response(error_msg, "unexpected_error") async def get_blocks_external() -> list[dict[str, Any]] | None: diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py index 6b3784e323..74011c7e95 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/create_agent.py @@ -9,6 +9,7 @@ from .agent_generator import ( AgentGeneratorNotConfiguredError, decompose_goal, generate_agent, + get_user_message_for_error, save_agent_to_library, ) from .base import BaseTool @@ -117,11 +118,29 @@ class CreateAgentTool(BaseTool): if decomposition_result is None: return ErrorResponse( - message="Failed to analyze the goal. The agent generation service may be unavailable or timed out. Please try again.", + message="Failed to analyze the goal. The agent generation service may be unavailable. Please try again.", error="decomposition_failed", + details={"description": description[:100]}, + session_id=session_id, + ) + + # Check if the result is an error from the external service + if decomposition_result.get("type") == "error": + error_msg = decomposition_result.get("error", "Unknown error") + error_type = decomposition_result.get("error_type", "unknown") + user_message = get_user_message_for_error( + error_type, + operation="analyze the goal", + llm_parse_message="The AI had trouble understanding this request. Please try rephrasing your goal.", + ) + return ErrorResponse( + message=user_message, + error=f"decomposition_failed:{error_type}", details={ - "description": description[:100] - }, # Include context for debugging + "description": description[:100], + "service_error": error_msg, + "error_type": error_type, + }, session_id=session_id, ) @@ -186,11 +205,30 @@ class CreateAgentTool(BaseTool): if agent_json is None: return ErrorResponse( - message="Failed to generate the agent. The agent generation service may be unavailable or timed out. Please try again.", + message="Failed to generate the agent. The agent generation service may be unavailable. Please try again.", error="generation_failed", + details={"description": description[:100]}, + session_id=session_id, + ) + + # Check if the result is an error from the external service + if isinstance(agent_json, dict) and agent_json.get("type") == "error": + error_msg = agent_json.get("error", "Unknown error") + error_type = agent_json.get("error_type", "unknown") + user_message = get_user_message_for_error( + error_type, + operation="generate the agent", + llm_parse_message="The AI had trouble generating the agent. Please try again or simplify your goal.", + validation_message="The generated agent failed validation. Please try rephrasing your goal.", + ) + return ErrorResponse( + message=user_message, + error=f"generation_failed:{error_type}", details={ - "description": description[:100] - }, # Include context for debugging + "description": description[:100], + "service_error": error_msg, + "error_type": error_type, + }, session_id=session_id, ) diff --git a/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py b/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py index 7c4da8ad43..ee8eee53ce 100644 --- a/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py +++ b/autogpt_platform/backend/backend/api/features/chat/tools/edit_agent.py @@ -9,6 +9,7 @@ from .agent_generator import ( AgentGeneratorNotConfiguredError, generate_agent_patch, get_agent_as_json, + get_user_message_for_error, save_agent_to_library, ) from .base import BaseTool @@ -152,6 +153,28 @@ class EditAgentTool(BaseTool): session_id=session_id, ) + # Check if the result is an error from the external service + if isinstance(result, dict) and result.get("type") == "error": + error_msg = result.get("error", "Unknown error") + error_type = result.get("error_type", "unknown") + user_message = get_user_message_for_error( + error_type, + operation="generate the changes", + llm_parse_message="The AI had trouble generating the changes. Please try again or simplify your request.", + validation_message="The generated changes failed validation. Please try rephrasing your request.", + ) + return ErrorResponse( + message=user_message, + error=f"update_generation_failed:{error_type}", + details={ + "agent_id": agent_id, + "changes": changes[:100], + "service_error": error_msg, + "error_type": error_type, + }, + session_id=session_id, + ) + # Check if LLM returned clarifying questions if result.get("type") == "clarifying_questions": questions = result.get("questions", []) diff --git a/autogpt_platform/backend/test/agent_generator/test_service.py b/autogpt_platform/backend/test/agent_generator/test_service.py index 81ff794532..fe7a1a7fdd 100644 --- a/autogpt_platform/backend/test/agent_generator/test_service.py +++ b/autogpt_platform/backend/test/agent_generator/test_service.py @@ -151,15 +151,20 @@ class TestDecomposeGoalExternal: @pytest.mark.asyncio async def test_decompose_goal_handles_http_error(self): """Test decomposition handles HTTP errors gracefully.""" + mock_response = MagicMock() + mock_response.status_code = 500 mock_client = AsyncMock() mock_client.post.side_effect = httpx.HTTPStatusError( - "Server error", request=MagicMock(), response=MagicMock() + "Server error", request=MagicMock(), response=mock_response ) with patch.object(service, "_get_client", return_value=mock_client): result = await service.decompose_goal_external("Build a chatbot") - assert result is None + assert result is not None + assert result.get("type") == "error" + assert result.get("error_type") == "http_error" + assert "Server error" in result.get("error", "") @pytest.mark.asyncio async def test_decompose_goal_handles_request_error(self): @@ -170,7 +175,10 @@ class TestDecomposeGoalExternal: with patch.object(service, "_get_client", return_value=mock_client): result = await service.decompose_goal_external("Build a chatbot") - assert result is None + assert result is not None + assert result.get("type") == "error" + assert result.get("error_type") == "connection_error" + assert "Connection failed" in result.get("error", "") @pytest.mark.asyncio async def test_decompose_goal_handles_service_error(self): @@ -179,6 +187,7 @@ class TestDecomposeGoalExternal: mock_response.json.return_value = { "success": False, "error": "Internal error", + "error_type": "internal_error", } mock_response.raise_for_status = MagicMock() @@ -188,7 +197,10 @@ class TestDecomposeGoalExternal: with patch.object(service, "_get_client", return_value=mock_client): result = await service.decompose_goal_external("Build a chatbot") - assert result is None + assert result is not None + assert result.get("type") == "error" + assert result.get("error") == "Internal error" + assert result.get("error_type") == "internal_error" class TestGenerateAgentExternal: @@ -236,7 +248,10 @@ class TestGenerateAgentExternal: with patch.object(service, "_get_client", return_value=mock_client): result = await service.generate_agent_external({"steps": []}) - assert result is None + assert result is not None + assert result.get("type") == "error" + assert result.get("error_type") == "connection_error" + assert "Connection failed" in result.get("error", "") class TestGenerateAgentPatchExternal: From 3b822cdaf7141cd0900644b927b104d4ec185c0b Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Thu, 29 Jan 2026 18:31:34 -0600 Subject: [PATCH 35/36] chore(branchlet): Remove docs pip install from postCreateCmd (#11883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changes šŸ—ļø - Removed `cd docs && pip install -r requirements.txt` from `postCreateCmd` in `.branchlet.json` - Docs dependencies will no longer be auto-installed during branchlet worktree creation ### Rationale The docs setup step was adding unnecessary overhead to the worktree creation process. Developers who need to work on documentation can manually install the docs requirements when needed. ### Checklist šŸ“‹ #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Verified branchlet worktree creation still works without the docs pip install step #### For configuration changes: - [x] `.env.default` is updated or already compatible with my changes - [x] `docker-compose.yml` is updated or already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) --- .branchlet.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.branchlet.json b/.branchlet.json index cc13ff9f74..d02cd60e20 100644 --- a/.branchlet.json +++ b/.branchlet.json @@ -29,8 +29,7 @@ "postCreateCmd": [ "cd autogpt_platform/autogpt_libs && poetry install", "cd autogpt_platform/backend && poetry install && poetry run prisma generate", - "cd autogpt_platform/frontend && pnpm install", - "cd docs && pip install -r requirements.txt" + "cd autogpt_platform/frontend && pnpm install" ], "terminalCommand": "code .", "deleteBranchWithWorktree": false From 582c6cad36b2ba0e675d22c617538e037f495d0b Mon Sep 17 00:00:00 2001 From: Otto Date: Fri, 30 Jan 2026 05:12:35 +0000 Subject: [PATCH 36/36] fix(e2e): Make E2E test data deterministic and fix flaky tests (#11890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes flaky E2E marketplace and library tests that were causing PRs to be removed from the merge queue. ## Root Cause 1. **Test data was probabilistic** - `e2e_test_data.py` used random chances (40% approve, then 20-50% feature), which could result in 0 featured agents 2. **Library pagination threshold wrong** - Checked `>= 10`, but page size is 20 3. **Fixed timeouts** - Used `waitForTimeout(2000)` / `waitForTimeout(10000)` instead of proper waits ## Changes ### Backend (`e2e_test_data.py`) - Add guaranteed minimums: 8 featured agents, 5 featured creators, 10 top agents - First N submissions are deterministically approved and featured - Increase agents per user from 15 → 25 (for pagination with page_size=20) - Fix library agent creation to use constants instead of hardcoded `10` ### Frontend Tests - `library.spec.ts`: Fix pagination threshold to `PAGE_SIZE` (20) - `library.page.ts`: Replace 2s timeout with `networkidle` + `waitForFunction` - `marketplace.page.ts`: Add `networkidle` wait, 30s waits in `getFirst*` methods - `marketplace.spec.ts`: Replace 10s timeout with `waitForFunction` - `marketplace-creator.spec.ts`: Add `networkidle` + element waits ## Related - Closes SECRT-1848, SECRT-1849 - Should unblock #11841 and other PRs in merge queue --------- Co-authored-by: Ubbe --- .../backend/test/e2e_test_data.py | 162 ++++++++++-------- .../frontend/src/tests/library.spec.ts | 13 +- .../src/tests/marketplace-creator.spec.ts | 3 + .../frontend/src/tests/marketplace.spec.ts | 11 +- .../frontend/src/tests/pages/library.page.ts | 26 +-- .../src/tests/pages/marketplace.page.ts | 15 +- 6 files changed, 136 insertions(+), 94 deletions(-) diff --git a/autogpt_platform/backend/test/e2e_test_data.py b/autogpt_platform/backend/test/e2e_test_data.py index d7576cdad3..7288197a90 100644 --- a/autogpt_platform/backend/test/e2e_test_data.py +++ b/autogpt_platform/backend/test/e2e_test_data.py @@ -43,19 +43,24 @@ faker = Faker() # Constants for data generation limits (reduced for E2E tests) NUM_USERS = 15 NUM_AGENT_BLOCKS = 30 -MIN_GRAPHS_PER_USER = 15 -MAX_GRAPHS_PER_USER = 15 +MIN_GRAPHS_PER_USER = 25 +MAX_GRAPHS_PER_USER = 25 MIN_NODES_PER_GRAPH = 3 MAX_NODES_PER_GRAPH = 6 MIN_PRESETS_PER_USER = 2 MAX_PRESETS_PER_USER = 3 -MIN_AGENTS_PER_USER = 15 -MAX_AGENTS_PER_USER = 15 +MIN_AGENTS_PER_USER = 25 +MAX_AGENTS_PER_USER = 25 MIN_EXECUTIONS_PER_GRAPH = 2 MAX_EXECUTIONS_PER_GRAPH = 8 MIN_REVIEWS_PER_VERSION = 2 MAX_REVIEWS_PER_VERSION = 5 +# Guaranteed minimums for marketplace tests (deterministic) +GUARANTEED_FEATURED_AGENTS = 8 +GUARANTEED_FEATURED_CREATORS = 5 +GUARANTEED_TOP_AGENTS = 10 + def get_image(): """Generate a consistent image URL using picsum.photos service.""" @@ -385,7 +390,7 @@ class TestDataCreator: library_agents = [] for user in self.users: - num_agents = 10 # Create exactly 10 agents per user + num_agents = random.randint(MIN_AGENTS_PER_USER, MAX_AGENTS_PER_USER) # Get available graphs for this user user_graphs = [ @@ -507,14 +512,17 @@ class TestDataCreator: existing_profiles, min(num_creators, len(existing_profiles)) ) - # Mark about 50% of creators as featured (more for testing) - num_featured = max(2, int(num_creators * 0.5)) + # Guarantee at least GUARANTEED_FEATURED_CREATORS featured creators + num_featured = max(GUARANTEED_FEATURED_CREATORS, int(num_creators * 0.5)) num_featured = min( num_featured, len(selected_profiles) ) # Don't exceed available profiles featured_profile_ids = set( random.sample([p.id for p in selected_profiles], num_featured) ) + print( + f"šŸŽÆ Creating {num_featured} featured creators (min: {GUARANTEED_FEATURED_CREATORS})" + ) for profile in selected_profiles: try: @@ -545,21 +553,25 @@ class TestDataCreator: return profiles async def create_test_store_submissions(self) -> List[Dict[str, Any]]: - """Create test store submissions using the API function.""" + """Create test store submissions using the API function. + + DETERMINISTIC: Guarantees minimum featured agents for E2E tests. + """ print("Creating test store submissions...") submissions = [] approved_submissions = [] + featured_count = 0 + submission_counter = 0 - # Create a special test submission for test123@gmail.com + # Create a special test submission for test123@gmail.com (ALWAYS approved + featured) test_user = next( (user for user in self.users if user["email"] == "test123@gmail.com"), None ) - if test_user: - # Special test data for consistent testing + if test_user and self.agent_graphs: test_submission_data = { "user_id": test_user["id"], - "agent_id": self.agent_graphs[0]["id"], # Use first available graph + "agent_id": self.agent_graphs[0]["id"], "agent_version": 1, "slug": "test-agent-submission", "name": "Test Agent Submission", @@ -580,37 +592,24 @@ class TestDataCreator: submissions.append(test_submission.model_dump()) print("āœ… Created special test store submission for test123@gmail.com") - # Randomly approve, reject, or leave pending the test submission + # ALWAYS approve and feature the test submission if test_submission.store_listing_version_id: - random_value = random.random() - if random_value < 0.4: # 40% chance to approve - approved_submission = await review_store_submission( - store_listing_version_id=test_submission.store_listing_version_id, - is_approved=True, - external_comments="Test submission approved", - internal_comments="Auto-approved test submission", - reviewer_id=test_user["id"], - ) - approved_submissions.append(approved_submission.model_dump()) - print("āœ… Approved test store submission") + approved_submission = await review_store_submission( + store_listing_version_id=test_submission.store_listing_version_id, + is_approved=True, + external_comments="Test submission approved", + internal_comments="Auto-approved test submission", + reviewer_id=test_user["id"], + ) + approved_submissions.append(approved_submission.model_dump()) + print("āœ… Approved test store submission") - # Mark approved submission as featured - await prisma.storelistingversion.update( - where={"id": test_submission.store_listing_version_id}, - data={"isFeatured": True}, - ) - print("🌟 Marked test agent as FEATURED") - elif random_value < 0.7: # 30% chance to reject (40% to 70%) - await review_store_submission( - store_listing_version_id=test_submission.store_listing_version_id, - is_approved=False, - external_comments="Test submission rejected - needs improvements", - internal_comments="Auto-rejected test submission for E2E testing", - reviewer_id=test_user["id"], - ) - print("āŒ Rejected test store submission") - else: # 30% chance to leave pending (70% to 100%) - print("ā³ Left test submission pending for review") + await prisma.storelistingversion.update( + where={"id": test_submission.store_listing_version_id}, + data={"isFeatured": True}, + ) + featured_count += 1 + print("🌟 Marked test agent as FEATURED") except Exception as e: print(f"Error creating test store submission: {e}") @@ -620,7 +619,6 @@ class TestDataCreator: # Create regular submissions for all users for user in self.users: - # Get available graphs for this specific user user_graphs = [ g for g in self.agent_graphs if g.get("userId") == user["id"] ] @@ -631,18 +629,17 @@ class TestDataCreator: ) continue - # Create exactly 4 store submissions per user for submission_index in range(4): graph = random.choice(user_graphs) + submission_counter += 1 try: print( - f"Creating store submission for user {user['id']} with graph {graph['id']} (owner: {graph.get('userId')})" + f"Creating store submission for user {user['id']} with graph {graph['id']}" ) - # Use the API function to create store submission with correct parameters submission = await create_store_submission( - user_id=user["id"], # Must match graph's userId + user_id=user["id"], agent_id=graph["id"], agent_version=graph.get("version", 1), slug=faker.slug(), @@ -651,22 +648,24 @@ class TestDataCreator: video_url=get_video_url() if random.random() < 0.3 else None, image_urls=[get_image() for _ in range(3)], description=faker.text(), - categories=[ - get_category() - ], # Single category from predefined list + categories=[get_category()], changes_summary="Initial E2E test submission", ) submissions.append(submission.model_dump()) print(f"āœ… Created store submission: {submission.name}") - # Randomly approve, reject, or leave pending the submission if submission.store_listing_version_id: - random_value = random.random() - if random_value < 0.4: # 40% chance to approve - try: - # Pick a random user as the reviewer (admin) - reviewer_id = random.choice(self.users)["id"] + # DETERMINISTIC: First N submissions are always approved + # First GUARANTEED_FEATURED_AGENTS of those are always featured + should_approve = ( + submission_counter <= GUARANTEED_TOP_AGENTS + or random.random() < 0.4 + ) + should_feature = featured_count < GUARANTEED_FEATURED_AGENTS + if should_approve: + try: + reviewer_id = random.choice(self.users)["id"] approved_submission = await review_store_submission( store_listing_version_id=submission.store_listing_version_id, is_approved=True, @@ -681,16 +680,7 @@ class TestDataCreator: f"āœ… Approved store submission: {submission.name}" ) - # Mark some agents as featured during creation (30% chance) - # More likely for creators and first submissions - is_creator = user["id"] in [ - p.get("userId") for p in self.profiles - ] - feature_chance = ( - 0.5 if is_creator else 0.2 - ) # 50% for creators, 20% for others - - if random.random() < feature_chance: + if should_feature: try: await prisma.storelistingversion.update( where={ @@ -698,8 +688,25 @@ class TestDataCreator: }, data={"isFeatured": True}, ) + featured_count += 1 print( - f"🌟 Marked agent as FEATURED: {submission.name}" + f"🌟 Marked agent as FEATURED ({featured_count}/{GUARANTEED_FEATURED_AGENTS}): {submission.name}" + ) + except Exception as e: + print( + f"Warning: Could not mark submission as featured: {e}" + ) + elif random.random() < 0.2: + try: + await prisma.storelistingversion.update( + where={ + "id": submission.store_listing_version_id + }, + data={"isFeatured": True}, + ) + featured_count += 1 + print( + f"🌟 Marked agent as FEATURED (bonus): {submission.name}" ) except Exception as e: print( @@ -710,11 +717,9 @@ class TestDataCreator: print( f"Warning: Could not approve submission {submission.name}: {e}" ) - elif random_value < 0.7: # 30% chance to reject (40% to 70%) + elif random.random() < 0.5: try: - # Pick a random user as the reviewer (admin) reviewer_id = random.choice(self.users)["id"] - await review_store_submission( store_listing_version_id=submission.store_listing_version_id, is_approved=False, @@ -729,7 +734,7 @@ class TestDataCreator: print( f"Warning: Could not reject submission {submission.name}: {e}" ) - else: # 30% chance to leave pending (70% to 100%) + else: print( f"ā³ Left submission pending for review: {submission.name}" ) @@ -743,9 +748,13 @@ class TestDataCreator: traceback.print_exc() continue + print("\nšŸ“Š Store Submissions Summary:") + print(f" Created: {len(submissions)}") + print(f" Approved: {len(approved_submissions)}") print( - f"Created {len(submissions)} store submissions, approved {len(approved_submissions)}" + f" Featured: {featured_count} (guaranteed min: {GUARANTEED_FEATURED_AGENTS})" ) + self.store_submissions = submissions return submissions @@ -825,12 +834,15 @@ class TestDataCreator: print(f"āœ… Agent blocks available: {len(self.agent_blocks)}") print(f"āœ… Agent graphs created: {len(self.agent_graphs)}") print(f"āœ… Library agents created: {len(self.library_agents)}") - print(f"āœ… Creator profiles updated: {len(self.profiles)} (some featured)") - print( - f"āœ… Store submissions created: {len(self.store_submissions)} (some marked as featured during creation)" - ) + print(f"āœ… Creator profiles updated: {len(self.profiles)}") + print(f"āœ… Store submissions created: {len(self.store_submissions)}") print(f"āœ… API keys created: {len(self.api_keys)}") print(f"āœ… Presets created: {len(self.presets)}") + print("\nšŸŽÆ Deterministic Guarantees:") + print(f" • Featured agents: >= {GUARANTEED_FEATURED_AGENTS}") + print(f" • Featured creators: >= {GUARANTEED_FEATURED_CREATORS}") + print(f" • Top agents (approved): >= {GUARANTEED_TOP_AGENTS}") + print(f" • Library agents per user: >= {MIN_AGENTS_PER_USER}") print("\nšŸš€ Your E2E test database is ready to use!") diff --git a/autogpt_platform/frontend/src/tests/library.spec.ts b/autogpt_platform/frontend/src/tests/library.spec.ts index 1972e94522..52941785e3 100644 --- a/autogpt_platform/frontend/src/tests/library.spec.ts +++ b/autogpt_platform/frontend/src/tests/library.spec.ts @@ -59,12 +59,13 @@ test.describe("Library", () => { }); test("pagination works correctly", async ({ page }, testInfo) => { - test.setTimeout(testInfo.timeout * 3); // Increase timeout for pagination operations + test.setTimeout(testInfo.timeout * 3); await page.goto("/library"); + const PAGE_SIZE = 20; const paginationResult = await libraryPage.testPagination(); - if (paginationResult.initialCount >= 10) { + if (paginationResult.initialCount >= PAGE_SIZE) { expect(paginationResult.finalCount).toBeGreaterThanOrEqual( paginationResult.initialCount, ); @@ -133,7 +134,10 @@ test.describe("Library", () => { test.expect(clearedSearchValue).toBe(""); }); - test("pagination while searching works correctly", async ({ page }) => { + test("pagination while searching works correctly", async ({ + page, + }, testInfo) => { + test.setTimeout(testInfo.timeout * 3); await page.goto("/library"); const allAgents = await libraryPage.getAgents(); @@ -152,9 +156,10 @@ test.describe("Library", () => { ); expect(matchingResults.length).toEqual(initialSearchResults.length); + const PAGE_SIZE = 20; const searchPaginationResult = await libraryPage.testPagination(); - if (searchPaginationResult.initialCount >= 10) { + if (searchPaginationResult.initialCount >= PAGE_SIZE) { expect(searchPaginationResult.finalCount).toBeGreaterThanOrEqual( searchPaginationResult.initialCount, ); diff --git a/autogpt_platform/frontend/src/tests/marketplace-creator.spec.ts b/autogpt_platform/frontend/src/tests/marketplace-creator.spec.ts index 3558f0672c..a41b652afb 100644 --- a/autogpt_platform/frontend/src/tests/marketplace-creator.spec.ts +++ b/autogpt_platform/frontend/src/tests/marketplace-creator.spec.ts @@ -69,9 +69,12 @@ test.describe("Marketplace Creator Page – Basic Functionality", () => { await marketplacePage.getFirstCreatorProfile(page); await firstCreatorProfile.click(); await page.waitForURL("**/marketplace/creator/**"); + await page.waitForLoadState("networkidle").catch(() => {}); + const firstAgent = page .locator('[data-testid="store-card"]:visible') .first(); + await firstAgent.waitFor({ state: "visible", timeout: 30000 }); await firstAgent.click(); await page.waitForURL("**/marketplace/agent/**"); diff --git a/autogpt_platform/frontend/src/tests/marketplace.spec.ts b/autogpt_platform/frontend/src/tests/marketplace.spec.ts index 774713dc82..44d89bf351 100644 --- a/autogpt_platform/frontend/src/tests/marketplace.spec.ts +++ b/autogpt_platform/frontend/src/tests/marketplace.spec.ts @@ -77,7 +77,6 @@ test.describe("Marketplace – Basic Functionality", () => { const firstFeaturedAgent = await marketplacePage.getFirstFeaturedAgent(page); - await firstFeaturedAgent.waitFor({ state: "visible" }); await firstFeaturedAgent.click(); await page.waitForURL("**/marketplace/agent/**"); await matchesUrl(page, /\/marketplace\/agent\/.+/); @@ -116,7 +115,15 @@ test.describe("Marketplace – Basic Functionality", () => { const searchTerm = page.getByText("DummyInput").first(); await isVisible(searchTerm); - await page.waitForTimeout(10000); + await page.waitForLoadState("networkidle").catch(() => {}); + + await page + .waitForFunction( + () => + document.querySelectorAll('[data-testid="store-card"]').length > 0, + { timeout: 15000 }, + ) + .catch(() => console.log("No search results appeared within timeout")); const results = await marketplacePage.getSearchResultsCount(page); expect(results).toBeGreaterThan(0); diff --git a/autogpt_platform/frontend/src/tests/pages/library.page.ts b/autogpt_platform/frontend/src/tests/pages/library.page.ts index 3a7695ec3a..03e98598b4 100644 --- a/autogpt_platform/frontend/src/tests/pages/library.page.ts +++ b/autogpt_platform/frontend/src/tests/pages/library.page.ts @@ -300,21 +300,27 @@ export class LibraryPage extends BasePage { async scrollToLoadMore(): Promise { console.log(`scrolling to load more agents`); - // Get initial agent count - const initialCount = await this.getAgentCount(); - console.log(`Initial agent count: ${initialCount}`); + const initialCount = await this.getAgentCountByListLength(); + console.log(`Initial agent count (DOM cards): ${initialCount}`); - // Scroll down to trigger pagination await this.scrollToBottom(); - // Wait for potential new agents to load - await this.page.waitForTimeout(2000); + await this.page + .waitForLoadState("networkidle", { timeout: 10000 }) + .catch(() => console.log("Network idle timeout, continuing...")); - // Check if more agents loaded - const newCount = await this.getAgentCount(); - console.log(`New agent count after scroll: ${newCount}`); + await this.page + .waitForFunction( + (prevCount) => + document.querySelectorAll('[data-testid="library-agent-card"]') + .length > prevCount, + initialCount, + { timeout: 5000 }, + ) + .catch(() => {}); - return; + const newCount = await this.getAgentCountByListLength(); + console.log(`New agent count after scroll (DOM cards): ${newCount}`); } async testPagination(): Promise<{ diff --git a/autogpt_platform/frontend/src/tests/pages/marketplace.page.ts b/autogpt_platform/frontend/src/tests/pages/marketplace.page.ts index 20f60c371a..115a7b2f12 100644 --- a/autogpt_platform/frontend/src/tests/pages/marketplace.page.ts +++ b/autogpt_platform/frontend/src/tests/pages/marketplace.page.ts @@ -9,6 +9,7 @@ export class MarketplacePage extends BasePage { async goto(page: Page) { await page.goto("/marketplace"); + await page.waitForLoadState("networkidle").catch(() => {}); } async getMarketplaceTitle(page: Page) { @@ -109,16 +110,24 @@ export class MarketplacePage extends BasePage { async getFirstFeaturedAgent(page: Page) { const { getId } = getSelectors(page); - return getId("featured-store-card").first(); + const card = getId("featured-store-card").first(); + await card.waitFor({ state: "visible", timeout: 30000 }); + return card; } async getFirstTopAgent() { - return this.page.locator('[data-testid="store-card"]:visible').first(); + const card = this.page + .locator('[data-testid="store-card"]:visible') + .first(); + await card.waitFor({ state: "visible", timeout: 30000 }); + return card; } async getFirstCreatorProfile(page: Page) { const { getId } = getSelectors(page); - return getId("creator-card").first(); + const card = getId("creator-card").first(); + await card.waitFor({ state: "visible", timeout: 30000 }); + return card; } async getSearchResultsCount(page: Page) {

e{UM_jh$%*f+(lz`)YUy!w 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 literal 0 HcmV?d00001 diff --git a/docs/integrations/.gitbook/assets/Select-AI-block.png b/docs/integrations/.gitbook/assets/Select-AI-block.png new file mode 100644 index 0000000000000000000000000000000000000000..d9c41db4a510f2fe7a8256fe04d124b42c84ad07 GIT binary patch literal 119144 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 literal 0 HcmV?d00001 diff --git a/docs/integrations/.gitbook/assets/e2b-dashboard.png b/docs/integrations/.gitbook/assets/e2b-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..456f1490cc1eba08b75bdfa6e3a414cdccbf9d3f GIT binary patch literal 515634 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