Compare commits
19 Commits
feature/vi
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d7134fc6 | ||
|
|
90466908a8 | ||
|
|
f9f984a8f4 | ||
|
|
fc87ed4e34 | ||
|
|
b0953654d9 | ||
|
|
c5069ca48f | ||
|
|
5d0cd88d98 | ||
|
|
033f58c075 | ||
|
|
40ef2d511f | ||
|
|
b714c0c221 | ||
|
|
ebabc4287e | ||
|
|
8b25e62959 | ||
|
|
35a13e3df5 | ||
|
|
2169b433c9 | ||
|
|
fa0b7029dd | ||
|
|
c20ca47bb0 | ||
|
|
7756e2d12d | ||
|
|
bc75d70e7d | ||
|
|
c1a1767034 |
@@ -93,5 +93,5 @@ jobs:
|
|||||||
|
|
||||||
Error logs:
|
Error logs:
|
||||||
${{ toJSON(fromJSON(steps.failure_details.outputs.result).errorLogs) }}
|
${{ toJSON(fromJSON(steps.failure_details.outputs.result).errorLogs) }}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
claude_args: "--allowedTools 'Edit,MultiEdit,Write,Read,Glob,Grep,LS,Bash(git:*),Bash(bun:*),Bash(npm:*),Bash(npx:*),Bash(gh:*)'"
|
claude_args: "--allowedTools 'Edit,MultiEdit,Write,Read,Glob,Grep,LS,Bash(git:*),Bash(bun:*),Bash(npm:*),Bash(npx:*),Bash(gh:*)'"
|
||||||
|
|||||||
4
.github/workflows/claude-dependabot.yml
vendored
@@ -7,7 +7,7 @@
|
|||||||
# - Provide actionable recommendations for the development team
|
# - Provide actionable recommendations for the development team
|
||||||
#
|
#
|
||||||
# Triggered on: Dependabot PRs (opened, synchronize)
|
# Triggered on: Dependabot PRs (opened, synchronize)
|
||||||
# Requirements: ANTHROPIC_API_KEY secret must be configured
|
# Requirements: CLAUDE_CODE_OAUTH_TOKEN secret must be configured
|
||||||
|
|
||||||
name: Claude Dependabot PR Review
|
name: Claude Dependabot PR Review
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@ jobs:
|
|||||||
id: claude_review
|
id: claude_review
|
||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@v1
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--allowedTools "Bash(npm:*),Bash(pnpm:*),Bash(poetry:*),Bash(git:*),Edit,Replace,NotebookEditCell,mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*)"
|
--allowedTools "Bash(npm:*),Bash(pnpm:*),Bash(poetry:*),Bash(git:*),Edit,Replace,NotebookEditCell,mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*)"
|
||||||
prompt: |
|
prompt: |
|
||||||
|
|||||||
2
.github/workflows/claude.yml
vendored
@@ -323,7 +323,7 @@ jobs:
|
|||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@v1
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
claude_args: |
|
claude_args: |
|
||||||
--allowedTools "Bash(npm:*),Bash(pnpm:*),Bash(poetry:*),Bash(git:*),Edit,Replace,NotebookEditCell,mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*), Bash(gh pr edit:*)"
|
--allowedTools "Bash(npm:*),Bash(pnpm:*),Bash(poetry:*),Bash(git:*),Edit,Replace,NotebookEditCell,mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*), Bash(gh pr edit:*)"
|
||||||
--model opus
|
--model opus
|
||||||
|
|||||||
78
.github/workflows/docs-block-sync.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Block Documentation Sync Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, dev]
|
||||||
|
paths:
|
||||||
|
- "autogpt_platform/backend/backend/blocks/**"
|
||||||
|
- "docs/integrations/**"
|
||||||
|
- "autogpt_platform/backend/scripts/generate_block_docs.py"
|
||||||
|
- ".github/workflows/docs-block-sync.yml"
|
||||||
|
pull_request:
|
||||||
|
branches: [master, dev]
|
||||||
|
paths:
|
||||||
|
- "autogpt_platform/backend/backend/blocks/**"
|
||||||
|
- "docs/integrations/**"
|
||||||
|
- "autogpt_platform/backend/scripts/generate_block_docs.py"
|
||||||
|
- ".github/workflows/docs-block-sync.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-docs-sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Set up Python dependency cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pypoetry
|
||||||
|
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
poetry-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install Poetry
|
||||||
|
run: |
|
||||||
|
cd autogpt_platform/backend
|
||||||
|
HEAD_POETRY_VERSION=$(python3 ../../.github/workflows/scripts/get_package_version_from_lockfile.py poetry)
|
||||||
|
echo "Found Poetry version ${HEAD_POETRY_VERSION} in backend/poetry.lock"
|
||||||
|
curl -sSL https://install.python-poetry.org | POETRY_VERSION=$HEAD_POETRY_VERSION python3 -
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: autogpt_platform/backend
|
||||||
|
run: |
|
||||||
|
poetry install --only main
|
||||||
|
poetry run prisma generate
|
||||||
|
|
||||||
|
- name: Check block documentation is in sync
|
||||||
|
working-directory: autogpt_platform/backend
|
||||||
|
run: |
|
||||||
|
echo "Checking if block documentation is in sync with code..."
|
||||||
|
poetry run python scripts/generate_block_docs.py --check
|
||||||
|
|
||||||
|
- name: Show diff if out of sync
|
||||||
|
if: failure()
|
||||||
|
working-directory: autogpt_platform/backend
|
||||||
|
run: |
|
||||||
|
echo "::error::Block documentation is out of sync with code!"
|
||||||
|
echo ""
|
||||||
|
echo "To fix this, run the following command locally:"
|
||||||
|
echo " cd autogpt_platform/backend && poetry run python scripts/generate_block_docs.py"
|
||||||
|
echo ""
|
||||||
|
echo "Then commit the updated documentation files."
|
||||||
|
echo ""
|
||||||
|
echo "Regenerating docs to show diff..."
|
||||||
|
poetry run python scripts/generate_block_docs.py
|
||||||
|
echo ""
|
||||||
|
echo "Changes detected:"
|
||||||
|
git diff ../../docs/integrations/ || true
|
||||||
95
.github/workflows/docs-claude-review.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: Claude Block Docs Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
paths:
|
||||||
|
- "docs/integrations/**"
|
||||||
|
- "autogpt_platform/backend/backend/blocks/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
# Only run for PRs from members/collaborators
|
||||||
|
if: |
|
||||||
|
github.event.pull_request.author_association == 'OWNER' ||
|
||||||
|
github.event.pull_request.author_association == 'MEMBER' ||
|
||||||
|
github.event.pull_request.author_association == 'COLLABORATOR'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Set up Python dependency cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pypoetry
|
||||||
|
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
poetry-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install Poetry
|
||||||
|
run: |
|
||||||
|
cd autogpt_platform/backend
|
||||||
|
HEAD_POETRY_VERSION=$(python3 ../../.github/workflows/scripts/get_package_version_from_lockfile.py poetry)
|
||||||
|
curl -sSL https://install.python-poetry.org | POETRY_VERSION=$HEAD_POETRY_VERSION python3 -
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: autogpt_platform/backend
|
||||||
|
run: |
|
||||||
|
poetry install --only main
|
||||||
|
poetry run prisma generate
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
claude_args: |
|
||||||
|
--allowedTools "Read,Glob,Grep,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||||
|
prompt: |
|
||||||
|
You are reviewing a PR that modifies block documentation or block code for AutoGPT.
|
||||||
|
|
||||||
|
## Your Task
|
||||||
|
Review the changes in this PR and provide constructive feedback. Focus on:
|
||||||
|
|
||||||
|
1. **Documentation Accuracy**: For any block code changes, verify that:
|
||||||
|
- Input/output tables in docs match the actual block schemas
|
||||||
|
- Description text accurately reflects what the block does
|
||||||
|
- Any new blocks have corresponding documentation
|
||||||
|
|
||||||
|
2. **Manual Content Quality**: Check manual sections (marked with `<!-- MANUAL: -->` markers):
|
||||||
|
- "How it works" sections should have clear technical explanations
|
||||||
|
- "Possible use case" sections should have practical, real-world examples
|
||||||
|
- Content should be helpful for users trying to understand the blocks
|
||||||
|
|
||||||
|
3. **Template Compliance**: Ensure docs follow the standard template:
|
||||||
|
- What it is (brief intro)
|
||||||
|
- What it does (description)
|
||||||
|
- How it works (technical explanation)
|
||||||
|
- Inputs table
|
||||||
|
- Outputs table
|
||||||
|
- Possible use case
|
||||||
|
|
||||||
|
4. **Cross-references**: Check that links and anchors are correct
|
||||||
|
|
||||||
|
## Review Process
|
||||||
|
1. First, get the PR diff to see what changed: `gh pr diff ${{ github.event.pull_request.number }}`
|
||||||
|
2. Read any modified block files to understand the implementation
|
||||||
|
3. Read corresponding documentation files to verify accuracy
|
||||||
|
4. Provide your feedback as a PR comment
|
||||||
|
|
||||||
|
Be constructive and specific. If everything looks good, say so!
|
||||||
|
If there are issues, explain what's wrong and suggest how to fix it.
|
||||||
194
.github/workflows/docs-enhance.yml
vendored
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
name: Enhance Block Documentation
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
block_pattern:
|
||||||
|
description: 'Block file pattern to enhance (e.g., "google/*.md" or "*" for all blocks)'
|
||||||
|
required: true
|
||||||
|
default: '*'
|
||||||
|
type: string
|
||||||
|
dry_run:
|
||||||
|
description: 'Dry run mode - show proposed changes without committing'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
max_blocks:
|
||||||
|
description: 'Maximum number of blocks to process (0 for unlimited)'
|
||||||
|
type: number
|
||||||
|
default: 10
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
enhance-docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Set up Python dependency cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pypoetry
|
||||||
|
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
poetry-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install Poetry
|
||||||
|
run: |
|
||||||
|
cd autogpt_platform/backend
|
||||||
|
HEAD_POETRY_VERSION=$(python3 ../../.github/workflows/scripts/get_package_version_from_lockfile.py poetry)
|
||||||
|
curl -sSL https://install.python-poetry.org | POETRY_VERSION=$HEAD_POETRY_VERSION python3 -
|
||||||
|
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: autogpt_platform/backend
|
||||||
|
run: |
|
||||||
|
poetry install --only main
|
||||||
|
poetry run prisma generate
|
||||||
|
|
||||||
|
- name: Run Claude Enhancement
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
claude_args: |
|
||||||
|
--allowedTools "Read,Edit,Glob,Grep,Write,Bash(git:*),Bash(gh:*),Bash(find:*),Bash(ls:*)"
|
||||||
|
prompt: |
|
||||||
|
You are enhancing block documentation for AutoGPT. Your task is to improve the MANUAL sections
|
||||||
|
of block documentation files by reading the actual block implementations and writing helpful content.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- Block pattern: ${{ inputs.block_pattern }}
|
||||||
|
- Dry run: ${{ inputs.dry_run }}
|
||||||
|
- Max blocks to process: ${{ inputs.max_blocks }}
|
||||||
|
|
||||||
|
## Your Task
|
||||||
|
|
||||||
|
1. **Find Documentation Files**
|
||||||
|
Find block documentation files matching the pattern in `docs/integrations/`
|
||||||
|
Pattern: ${{ inputs.block_pattern }}
|
||||||
|
|
||||||
|
Use: `find docs/integrations -name "*.md" -type f`
|
||||||
|
|
||||||
|
2. **For Each Documentation File** (up to ${{ inputs.max_blocks }} files):
|
||||||
|
|
||||||
|
a. Read the documentation file
|
||||||
|
|
||||||
|
b. Identify which block(s) it documents (look for the block class name)
|
||||||
|
|
||||||
|
c. Find and read the corresponding block implementation in `autogpt_platform/backend/backend/blocks/`
|
||||||
|
|
||||||
|
d. Improve the MANUAL sections:
|
||||||
|
|
||||||
|
**"How it works" section** (within `<!-- MANUAL: how_it_works -->` markers):
|
||||||
|
- Explain the technical flow of the block
|
||||||
|
- Describe what APIs or services it connects to
|
||||||
|
- Note any important configuration or prerequisites
|
||||||
|
- Keep it concise but informative (2-4 paragraphs)
|
||||||
|
|
||||||
|
**"Possible use case" section** (within `<!-- MANUAL: use_case -->` markers):
|
||||||
|
- Provide 2-3 practical, real-world examples
|
||||||
|
- Make them specific and actionable
|
||||||
|
- Show how this block could be used in an automation workflow
|
||||||
|
|
||||||
|
3. **Important Rules**
|
||||||
|
- ONLY modify content within `<!-- MANUAL: -->` and `<!-- END MANUAL -->` markers
|
||||||
|
- Do NOT modify auto-generated sections (inputs/outputs tables, descriptions)
|
||||||
|
- Keep content accurate based on the actual block implementation
|
||||||
|
- Write for users who may not be technical experts
|
||||||
|
|
||||||
|
4. **Output**
|
||||||
|
${{ inputs.dry_run == true && 'DRY RUN MODE: Show proposed changes for each file but do NOT actually edit the files. Describe what you would change.' || 'LIVE MODE: Actually edit the files to improve the documentation.' }}
|
||||||
|
|
||||||
|
## Example Improvements
|
||||||
|
|
||||||
|
**Before (How it works):**
|
||||||
|
```
|
||||||
|
_Add technical explanation here._
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (How it works):**
|
||||||
|
```
|
||||||
|
This block connects to the GitHub API to retrieve issue information. When executed,
|
||||||
|
it authenticates using your GitHub credentials and fetches issue details including
|
||||||
|
title, body, labels, and assignees.
|
||||||
|
|
||||||
|
The block requires a valid GitHub OAuth connection with repository access permissions.
|
||||||
|
It supports both public and private repositories you have access to.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Before (Possible use case):**
|
||||||
|
```
|
||||||
|
_Add practical use case examples here._
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (Possible use case):**
|
||||||
|
```
|
||||||
|
**Customer Support Automation**: Monitor a GitHub repository for new issues with
|
||||||
|
the "bug" label, then automatically create a ticket in your support system and
|
||||||
|
notify the on-call engineer via Slack.
|
||||||
|
|
||||||
|
**Release Notes Generation**: When a new release is published, gather all closed
|
||||||
|
issues since the last release and generate a summary for your changelog.
|
||||||
|
```
|
||||||
|
|
||||||
|
Begin by finding and listing the documentation files to process.
|
||||||
|
|
||||||
|
- name: Create PR with enhanced documentation
|
||||||
|
if: ${{ inputs.dry_run == false }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Check if there are changes
|
||||||
|
if git diff --quiet docs/integrations/; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure git
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
# Create branch and commit
|
||||||
|
BRANCH_NAME="docs/enhance-blocks-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
git checkout -b "$BRANCH_NAME"
|
||||||
|
git add docs/integrations/
|
||||||
|
git commit -m "docs: enhance block documentation with LLM-generated content
|
||||||
|
|
||||||
|
Pattern: ${{ inputs.block_pattern }}
|
||||||
|
Max blocks: ${{ inputs.max_blocks }}
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||||
|
|
||||||
|
# Push and create PR
|
||||||
|
git push -u origin "$BRANCH_NAME"
|
||||||
|
gh pr create \
|
||||||
|
--title "docs: LLM-enhanced block documentation" \
|
||||||
|
--body "## Summary
|
||||||
|
This PR contains LLM-enhanced documentation for block files matching pattern: \`${{ inputs.block_pattern }}\`
|
||||||
|
|
||||||
|
The following manual sections were improved:
|
||||||
|
- **How it works**: Technical explanations based on block implementations
|
||||||
|
- **Possible use case**: Practical, real-world examples
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
- [ ] Content is accurate based on block implementations
|
||||||
|
- [ ] Examples are practical and helpful
|
||||||
|
- [ ] No auto-generated sections were modified
|
||||||
|
|
||||||
|
---
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)" \
|
||||||
|
--base dev
|
||||||
38
.github/workflows/platform-frontend-ci.yml
vendored
@@ -128,7 +128,7 @@ jobs:
|
|||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
exitOnceUploaded: true
|
exitOnceUploaded: true
|
||||||
|
|
||||||
test:
|
e2e_test:
|
||||||
runs-on: big-boi
|
runs-on: big-boi
|
||||||
needs: setup
|
needs: setup
|
||||||
strategy:
|
strategy:
|
||||||
@@ -258,3 +258,39 @@ jobs:
|
|||||||
- name: Print Final Docker Compose logs
|
- name: Print Final Docker Compose logs
|
||||||
if: always()
|
if: always()
|
||||||
run: docker compose -f ../docker-compose.yml logs
|
run: docker compose -f ../docker-compose.yml logs
|
||||||
|
|
||||||
|
integration_test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.18.0"
|
||||||
|
|
||||||
|
- name: Enable corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
|
- name: Restore dependencies cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.pnpm-store
|
||||||
|
key: ${{ needs.setup.outputs.cache-key }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
|
||||||
|
${{ runner.os }}-pnpm-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate API client
|
||||||
|
run: pnpm generate:api
|
||||||
|
|
||||||
|
- name: Run Integration Tests
|
||||||
|
run: pnpm test:unit
|
||||||
|
|||||||
26
AGENTS.md
@@ -16,6 +16,32 @@ See `docs/content/platform/getting-started.md` for setup instructions.
|
|||||||
- Format Python code with `poetry run format`.
|
- Format Python code with `poetry run format`.
|
||||||
- Format frontend code using `pnpm format`.
|
- Format frontend code using `pnpm format`.
|
||||||
|
|
||||||
|
|
||||||
|
## 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 strictly needed
|
||||||
|
- Avoid comments at all times unless the code is very complex
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).
|
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ If you get any pushback or hit complex block conditions check the new_blocks gui
|
|||||||
3. Write tests alongside the route file
|
3. Write tests alongside the route file
|
||||||
4. Run `poetry run test` to verify
|
4. Run `poetry run test` to verify
|
||||||
|
|
||||||
**Frontend feature development:**
|
### Frontend guidelines:
|
||||||
|
|
||||||
See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference:
|
See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference:
|
||||||
|
|
||||||
@@ -217,6 +217,14 @@ See `/frontend/CONTRIBUTING.md` for complete patterns. Quick reference:
|
|||||||
4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only
|
4. **Styling**: Tailwind CSS only, use design tokens, Phosphor Icons only
|
||||||
5. **Testing**: Add Storybook stories for new components, Playwright for E2E
|
5. **Testing**: Add Storybook stories for new components, Playwright for E2E
|
||||||
6. **Code conventions**: Function declarations (not arrow functions) for components/handlers
|
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
|
||||||
|
|
||||||
### Security Implementation
|
### Security Implementation
|
||||||
|
|
||||||
|
|||||||
@@ -290,6 +290,11 @@ async def _cache_session(session: ChatSession) -> None:
|
|||||||
await async_redis.setex(redis_key, config.session_ttl, session.model_dump_json())
|
await async_redis.setex(redis_key, config.session_ttl, session.model_dump_json())
|
||||||
|
|
||||||
|
|
||||||
|
async def cache_chat_session(session: ChatSession) -> None:
|
||||||
|
"""Cache a chat session without persisting to the database."""
|
||||||
|
await _cache_session(session)
|
||||||
|
|
||||||
|
|
||||||
async def _get_session_from_db(session_id: str) -> ChatSession | None:
|
async def _get_session_from_db(session_id: str) -> ChatSession | None:
|
||||||
"""Get a chat session from the database."""
|
"""Get a chat session from the database."""
|
||||||
prisma_session = await chat_db.get_chat_session(session_id)
|
prisma_session = await chat_db.get_chat_session(session_id)
|
||||||
|
|||||||
@@ -172,12 +172,12 @@ async def get_session(
|
|||||||
user_id: The optional authenticated user ID, or None for anonymous access.
|
user_id: The optional authenticated user ID, or None for anonymous access.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SessionDetailResponse: Details for the requested session; raises NotFoundError if not found.
|
SessionDetailResponse: Details for the requested session, or None if not found.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
session = await get_chat_session(session_id, user_id)
|
session = await get_chat_session(session_id, user_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise NotFoundError(f"Session {session_id} not found")
|
raise NotFoundError(f"Session {session_id} not found.")
|
||||||
|
|
||||||
messages = [message.model_dump() for message in session.messages]
|
messages = [message.model_dump() for message in session.messages]
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -222,6 +222,8 @@ async def stream_chat_post(
|
|||||||
session = await _validate_and_get_session(session_id, user_id)
|
session = await _validate_and_get_session(session_id, user_id)
|
||||||
|
|
||||||
async def event_generator() -> AsyncGenerator[str, None]:
|
async def event_generator() -> AsyncGenerator[str, None]:
|
||||||
|
chunk_count = 0
|
||||||
|
first_chunk_type: str | None = None
|
||||||
async for chunk in chat_service.stream_chat_completion(
|
async for chunk in chat_service.stream_chat_completion(
|
||||||
session_id,
|
session_id,
|
||||||
request.message,
|
request.message,
|
||||||
@@ -230,7 +232,26 @@ async def stream_chat_post(
|
|||||||
session=session, # Pass pre-fetched session to avoid double-fetch
|
session=session, # Pass pre-fetched session to avoid double-fetch
|
||||||
context=request.context,
|
context=request.context,
|
||||||
):
|
):
|
||||||
|
if chunk_count < 3:
|
||||||
|
logger.info(
|
||||||
|
"Chat stream chunk",
|
||||||
|
extra={
|
||||||
|
"session_id": session_id,
|
||||||
|
"chunk_type": str(chunk.type),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not first_chunk_type:
|
||||||
|
first_chunk_type = str(chunk.type)
|
||||||
|
chunk_count += 1
|
||||||
yield chunk.to_sse()
|
yield chunk.to_sse()
|
||||||
|
logger.info(
|
||||||
|
"Chat stream completed",
|
||||||
|
extra={
|
||||||
|
"session_id": session_id,
|
||||||
|
"chunk_count": chunk_count,
|
||||||
|
"first_chunk_type": first_chunk_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
# AI SDK protocol termination
|
# AI SDK protocol termination
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
@@ -275,6 +296,8 @@ async def stream_chat_get(
|
|||||||
session = await _validate_and_get_session(session_id, user_id)
|
session = await _validate_and_get_session(session_id, user_id)
|
||||||
|
|
||||||
async def event_generator() -> AsyncGenerator[str, None]:
|
async def event_generator() -> AsyncGenerator[str, None]:
|
||||||
|
chunk_count = 0
|
||||||
|
first_chunk_type: str | None = None
|
||||||
async for chunk in chat_service.stream_chat_completion(
|
async for chunk in chat_service.stream_chat_completion(
|
||||||
session_id,
|
session_id,
|
||||||
message,
|
message,
|
||||||
@@ -282,7 +305,26 @@ async def stream_chat_get(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
session=session, # Pass pre-fetched session to avoid double-fetch
|
session=session, # Pass pre-fetched session to avoid double-fetch
|
||||||
):
|
):
|
||||||
|
if chunk_count < 3:
|
||||||
|
logger.info(
|
||||||
|
"Chat stream chunk",
|
||||||
|
extra={
|
||||||
|
"session_id": session_id,
|
||||||
|
"chunk_type": str(chunk.type),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not first_chunk_type:
|
||||||
|
first_chunk_type = str(chunk.type)
|
||||||
|
chunk_count += 1
|
||||||
yield chunk.to_sse()
|
yield chunk.to_sse()
|
||||||
|
logger.info(
|
||||||
|
"Chat stream completed",
|
||||||
|
extra={
|
||||||
|
"session_id": session_id,
|
||||||
|
"chunk_count": chunk_count,
|
||||||
|
"first_chunk_type": first_chunk_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
# AI SDK protocol termination
|
# AI SDK protocol termination
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from asyncio import CancelledError
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import orjson
|
import orjson
|
||||||
from langfuse import Langfuse
|
from langfuse import get_client, propagate_attributes
|
||||||
|
from langfuse.openai import openai # type: ignore
|
||||||
from openai import (
|
from openai import (
|
||||||
APIConnectionError,
|
APIConnectionError,
|
||||||
APIError,
|
APIError,
|
||||||
APIStatusError,
|
APIStatusError,
|
||||||
AsyncOpenAI,
|
PermissionDeniedError,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
)
|
)
|
||||||
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam
|
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam
|
||||||
@@ -21,12 +24,12 @@ from backend.data.understanding import (
|
|||||||
from backend.util.exceptions import NotFoundError
|
from backend.util.exceptions import NotFoundError
|
||||||
from backend.util.settings import Settings
|
from backend.util.settings import Settings
|
||||||
|
|
||||||
from . import db as chat_db
|
|
||||||
from .config import ChatConfig
|
from .config import ChatConfig
|
||||||
from .model import (
|
from .model import (
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatSession,
|
ChatSession,
|
||||||
Usage,
|
Usage,
|
||||||
|
cache_chat_session,
|
||||||
get_chat_session,
|
get_chat_session,
|
||||||
update_session_title,
|
update_session_title,
|
||||||
upsert_chat_session,
|
upsert_chat_session,
|
||||||
@@ -50,10 +53,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
config = ChatConfig()
|
config = ChatConfig()
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
client = AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
|
client = openai.AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
|
||||||
|
|
||||||
# Langfuse client (lazy initialization)
|
|
||||||
_langfuse_client: Langfuse | None = None
|
langfuse = get_client()
|
||||||
|
|
||||||
|
|
||||||
class LangfuseNotConfiguredError(Exception):
|
class LangfuseNotConfiguredError(Exception):
|
||||||
@@ -69,65 +72,6 @@ def _is_langfuse_configured() -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_langfuse_client() -> Langfuse:
|
|
||||||
"""Get or create the Langfuse client for prompt management and tracing."""
|
|
||||||
global _langfuse_client
|
|
||||||
if _langfuse_client is None:
|
|
||||||
if not _is_langfuse_configured():
|
|
||||||
raise LangfuseNotConfiguredError(
|
|
||||||
"Langfuse is not configured. The chat feature requires Langfuse for prompt management. "
|
|
||||||
"Please set the LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY environment variables."
|
|
||||||
)
|
|
||||||
_langfuse_client = Langfuse(
|
|
||||||
public_key=settings.secrets.langfuse_public_key,
|
|
||||||
secret_key=settings.secrets.langfuse_secret_key,
|
|
||||||
host=settings.secrets.langfuse_host or "https://cloud.langfuse.com",
|
|
||||||
)
|
|
||||||
return _langfuse_client
|
|
||||||
|
|
||||||
|
|
||||||
def _get_environment() -> str:
|
|
||||||
"""Get the current environment name for Langfuse tagging."""
|
|
||||||
return settings.config.app_env.value
|
|
||||||
|
|
||||||
|
|
||||||
def _get_langfuse_prompt() -> str:
|
|
||||||
"""Fetch the latest production prompt from Langfuse.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The compiled prompt text from Langfuse.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If Langfuse is unavailable or prompt fetch fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
langfuse = _get_langfuse_client()
|
|
||||||
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
|
|
||||||
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
|
|
||||||
compiled = prompt.compile()
|
|
||||||
logger.info(
|
|
||||||
f"Fetched prompt '{config.langfuse_prompt_name}' from Langfuse "
|
|
||||||
f"(version: {prompt.version})"
|
|
||||||
)
|
|
||||||
return compiled
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to fetch prompt from Langfuse: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def _is_first_session(user_id: str) -> bool:
|
|
||||||
"""Check if this is the user's first chat session.
|
|
||||||
|
|
||||||
Returns True if the user has 1 or fewer sessions (meaning this is their first).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
session_count = await chat_db.get_user_session_count(user_id)
|
|
||||||
return session_count <= 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to check session count for user {user_id}: {e}")
|
|
||||||
return False # Default to non-onboarding if we can't check
|
|
||||||
|
|
||||||
|
|
||||||
async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
|
async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
|
||||||
"""Build the full system prompt including business understanding if available.
|
"""Build the full system prompt including business understanding if available.
|
||||||
|
|
||||||
@@ -139,8 +83,6 @@ async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
|
|||||||
Tuple of (compiled prompt string, Langfuse prompt object for tracing)
|
Tuple of (compiled prompt string, Langfuse prompt object for tracing)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
langfuse = _get_langfuse_client()
|
|
||||||
|
|
||||||
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
|
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
|
||||||
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
|
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
|
||||||
|
|
||||||
@@ -158,7 +100,7 @@ async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
|
|||||||
context = "This is the first time you are meeting the user. Greet them and introduce them to the platform"
|
context = "This is the first time you are meeting the user. Greet them and introduce them to the platform"
|
||||||
|
|
||||||
compiled = prompt.compile(users_information=context)
|
compiled = prompt.compile(users_information=context)
|
||||||
return compiled, prompt
|
return compiled, understanding
|
||||||
|
|
||||||
|
|
||||||
async def _generate_session_title(message: str) -> str | None:
|
async def _generate_session_title(message: str) -> str | None:
|
||||||
@@ -217,6 +159,7 @@ async def assign_user_to_session(
|
|||||||
async def stream_chat_completion(
|
async def stream_chat_completion(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
message: str | None = None,
|
message: str | None = None,
|
||||||
|
tool_call_response: str | None = None,
|
||||||
is_user_message: bool = True,
|
is_user_message: bool = True,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
retry_count: int = 0,
|
retry_count: int = 0,
|
||||||
@@ -256,11 +199,6 @@ async def stream_chat_completion(
|
|||||||
yield StreamFinish()
|
yield StreamFinish()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Langfuse observations will be created after session is loaded (need messages for input)
|
|
||||||
# Initialize to None so finally block can safely check and end them
|
|
||||||
trace = None
|
|
||||||
generation = None
|
|
||||||
|
|
||||||
# Only fetch from Redis if session not provided (initial call)
|
# Only fetch from Redis if session not provided (initial call)
|
||||||
if session is None:
|
if session is None:
|
||||||
session = await get_chat_session(session_id, user_id)
|
session = await get_chat_session(session_id, user_id)
|
||||||
@@ -336,297 +274,349 @@ async def stream_chat_completion(
|
|||||||
asyncio.create_task(_update_title())
|
asyncio.create_task(_update_title())
|
||||||
|
|
||||||
# Build system prompt with business understanding
|
# Build system prompt with business understanding
|
||||||
system_prompt, langfuse_prompt = await _build_system_prompt(user_id)
|
system_prompt, understanding = await _build_system_prompt(user_id)
|
||||||
|
|
||||||
# Build input messages including system prompt for complete Langfuse logging
|
|
||||||
trace_input_messages = [{"role": "system", "content": system_prompt}] + [
|
|
||||||
m.model_dump() for m in session.messages
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create Langfuse trace for this LLM call (each call gets its own trace, grouped by session_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
|
# Using v3 SDK: start_observation creates a root span, update_trace sets trace-level attributes
|
||||||
try:
|
input = message
|
||||||
langfuse = _get_langfuse_client()
|
if not message and tool_call_response:
|
||||||
env = _get_environment()
|
input = tool_call_response
|
||||||
trace = langfuse.start_observation(
|
|
||||||
name="chat_completion",
|
langfuse = get_client()
|
||||||
input={"messages": trace_input_messages},
|
with langfuse.start_as_current_observation(
|
||||||
metadata={
|
as_type="span",
|
||||||
"environment": env,
|
name="user-copilot-request",
|
||||||
"model": config.model,
|
input=input,
|
||||||
"message_count": len(session.messages),
|
) as span:
|
||||||
"prompt_name": langfuse_prompt.name if langfuse_prompt else None,
|
with propagate_attributes(
|
||||||
"prompt_version": langfuse_prompt.version if langfuse_prompt else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Set trace-level attributes (session_id, user_id, tags)
|
|
||||||
trace.update_trace(
|
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
tags=[env, "copilot"],
|
tags=["copilot"],
|
||||||
)
|
metadata={
|
||||||
except Exception as e:
|
"users_information": format_understanding_for_prompt(understanding)[
|
||||||
logger.warning(f"Failed to create Langfuse trace: {e}")
|
:200
|
||||||
|
] # langfuse only accepts upto to 200 chars
|
||||||
|
},
|
||||||
|
):
|
||||||
|
|
||||||
# Initialize variables that will be used in finally block (must be defined before try)
|
# Initialize variables that will be used in finally block (must be defined before try)
|
||||||
assistant_response = ChatMessage(
|
assistant_response = ChatMessage(
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content="",
|
content="",
|
||||||
)
|
|
||||||
accumulated_tool_calls: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
# Wrap main logic in try/finally to ensure Langfuse observations are always ended
|
|
||||||
try:
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Create Langfuse generation for each LLM call, linked to the prompt
|
|
||||||
# Using v3 SDK: start_observation with as_type="generation"
|
|
||||||
generation = (
|
|
||||||
trace.start_observation(
|
|
||||||
as_type="generation",
|
|
||||||
name="llm_call",
|
|
||||||
model=config.model,
|
|
||||||
input={"messages": trace_input_messages},
|
|
||||||
prompt=langfuse_prompt,
|
|
||||||
)
|
)
|
||||||
if trace
|
accumulated_tool_calls: list[dict[str, Any]] = []
|
||||||
else None
|
has_saved_assistant_message = False
|
||||||
)
|
has_appended_streaming_message = False
|
||||||
|
last_cache_time = 0.0
|
||||||
|
last_cache_content_len = 0
|
||||||
|
|
||||||
try:
|
# Wrap main logic in try/finally to ensure Langfuse observations are always ended
|
||||||
async for chunk in _stream_chat_chunks(
|
has_yielded_end = False
|
||||||
session=session,
|
has_yielded_error = False
|
||||||
tools=tools,
|
has_done_tool_call = False
|
||||||
system_prompt=system_prompt,
|
has_received_text = False
|
||||||
text_block_id=text_block_id,
|
text_streaming_ended = False
|
||||||
):
|
tool_response_messages: list[ChatMessage] = []
|
||||||
|
should_retry = False
|
||||||
|
|
||||||
if isinstance(chunk, StreamTextStart):
|
# Generate unique IDs for AI SDK protocol
|
||||||
# Emit text-start before first text delta
|
import uuid as uuid_module
|
||||||
if not has_received_text:
|
|
||||||
|
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
|
||||||
|
):
|
||||||
|
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
|
yield chunk
|
||||||
elif isinstance(chunk, StreamTextDelta):
|
elif isinstance(chunk, StreamTextEnd):
|
||||||
delta = chunk.delta or ""
|
# Emit text-end after text completes
|
||||||
assert assistant_response.content is not None
|
if has_received_text and not text_streaming_ended:
|
||||||
assistant_response.content += delta
|
text_streaming_ended = True
|
||||||
has_received_text = True
|
if assistant_response.content:
|
||||||
yield chunk
|
logger.warn(
|
||||||
elif isinstance(chunk, StreamTextEnd):
|
f"StreamTextEnd: Attempting to set output {assistant_response.content}"
|
||||||
# Emit text-end after text completes
|
)
|
||||||
if has_received_text and not text_streaming_ended:
|
span.update_trace(output=assistant_response.content)
|
||||||
text_streaming_ended = True
|
span.update(output=assistant_response.content)
|
||||||
yield chunk
|
yield chunk
|
||||||
elif isinstance(chunk, StreamToolInputStart):
|
elif isinstance(chunk, StreamToolInputStart):
|
||||||
# Emit text-end before first tool call, but only if we've received text
|
# 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:
|
if has_received_text and not text_streaming_ended:
|
||||||
yield StreamTextEnd(id=text_block_id)
|
yield StreamTextEnd(id=text_block_id)
|
||||||
text_streaming_ended = True
|
text_streaming_ended = True
|
||||||
has_yielded_end = True
|
|
||||||
yield chunk
|
yield chunk
|
||||||
elif isinstance(chunk, StreamError):
|
elif isinstance(chunk, StreamToolInputAvailable):
|
||||||
has_yielded_error = True
|
# Accumulate tool calls in OpenAI format
|
||||||
elif isinstance(chunk, StreamUsage):
|
accumulated_tool_calls.append(
|
||||||
session.usage.append(
|
{
|
||||||
Usage(
|
"id": chunk.toolCallId,
|
||||||
prompt_tokens=chunk.promptTokens,
|
"type": "function",
|
||||||
completion_tokens=chunk.completionTokens,
|
"function": {
|
||||||
total_tokens=chunk.totalTokens,
|
"name": chunk.toolName,
|
||||||
|
"arguments": orjson.dumps(chunk.input).decode(
|
||||||
|
"utf-8"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
elif isinstance(chunk, StreamToolOutputAvailable):
|
||||||
else:
|
result_content = (
|
||||||
logger.error(f"Unknown chunk type: {type(chunk)}", exc_info=True)
|
chunk.output
|
||||||
except Exception as e:
|
if isinstance(chunk.output, str)
|
||||||
logger.error(f"Error during stream: {e!s}", exc_info=True)
|
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
|
||||||
|
|
||||||
# Check if this is a retryable error (JSON parsing, incomplete tool calls, etc.)
|
# Save assistant message before yielding finish to ensure it's persisted
|
||||||
is_retryable = isinstance(e, (orjson.JSONDecodeError, KeyError, TypeError))
|
# 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 is_retryable and retry_count < config.max_retries:
|
if messages_to_save_early:
|
||||||
logger.info(
|
session.messages.extend(messages_to_save_early)
|
||||||
f"Retryable error encountered. Attempt {retry_count + 1}/{config.max_retries}"
|
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)
|
||||||
|
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)
|
||||||
)
|
)
|
||||||
should_retry = True
|
|
||||||
else:
|
if is_retryable and retry_count < config.max_retries:
|
||||||
# Non-retryable error or max retries exceeded
|
logger.info(
|
||||||
# Save any partial progress before reporting error
|
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,
|
||||||
|
):
|
||||||
|
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] = []
|
messages_to_save: list[ChatMessage] = []
|
||||||
|
|
||||||
# Add assistant message if it has content or tool calls
|
# Add assistant message with tool_calls if any
|
||||||
if accumulated_tool_calls:
|
if accumulated_tool_calls:
|
||||||
assistant_response.tool_calls = accumulated_tool_calls
|
assistant_response.tool_calls = accumulated_tool_calls
|
||||||
if assistant_response.content or assistant_response.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)
|
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
|
# Add tool response messages after assistant message
|
||||||
messages_to_save.extend(tool_response_messages)
|
messages_to_save.extend(tool_response_messages)
|
||||||
|
logger.info(
|
||||||
session.messages.extend(messages_to_save)
|
f"Saving {len(tool_response_messages)} tool response messages, "
|
||||||
await upsert_chat_session(session)
|
f"total_to_save={len(messages_to_save)}"
|
||||||
|
|
||||||
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
|
|
||||||
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 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)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.messages.extend(messages_to_save)
|
|
||||||
logger.info(
|
|
||||||
f"Extended session messages, new message_count={len(session.messages)}"
|
|
||||||
)
|
|
||||||
await upsert_chat_session(session)
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
):
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Always end Langfuse observations to prevent resource leaks
|
|
||||||
# Guard against None and catch errors to avoid masking original exceptions
|
|
||||||
if generation is not None:
|
|
||||||
try:
|
|
||||||
latest_usage = session.usage[-1] if session.usage else None
|
|
||||||
generation.update(
|
|
||||||
model=config.model,
|
|
||||||
output={
|
|
||||||
"content": assistant_response.content,
|
|
||||||
"tool_calls": accumulated_tool_calls or None,
|
|
||||||
},
|
|
||||||
usage_details=(
|
|
||||||
{
|
|
||||||
"input": latest_usage.prompt_tokens,
|
|
||||||
"output": latest_usage.completion_tokens,
|
|
||||||
"total": latest_usage.total_tokens,
|
|
||||||
}
|
|
||||||
if latest_usage
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
generation.end()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to end Langfuse generation: {e}")
|
|
||||||
|
|
||||||
if trace is not None:
|
if messages_to_save:
|
||||||
try:
|
session.messages.extend(messages_to_save)
|
||||||
if accumulated_tool_calls:
|
logger.info(
|
||||||
trace.update_trace(output={"tool_calls": accumulated_tool_calls})
|
f"Extended session messages, new message_count={len(session.messages)}"
|
||||||
else:
|
)
|
||||||
trace.update_trace(output={"response": assistant_response.content})
|
if messages_to_save or has_appended_streaming_message:
|
||||||
trace.end()
|
await upsert_chat_session(session)
|
||||||
except Exception as e:
|
else:
|
||||||
logger.warning(f"Failed to end Langfuse trace: {e}")
|
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
|
# Retry configuration for OpenAI API calls
|
||||||
@@ -654,6 +644,12 @@ def _is_retryable_error(error: Exception) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_region_blocked_error(error: Exception) -> bool:
|
||||||
|
if isinstance(error, PermissionDeniedError):
|
||||||
|
return "not available in your region" in str(error).lower()
|
||||||
|
return "not available in your region" in str(error).lower()
|
||||||
|
|
||||||
|
|
||||||
async def _stream_chat_chunks(
|
async def _stream_chat_chunks(
|
||||||
session: ChatSession,
|
session: ChatSession,
|
||||||
tools: list[ChatCompletionToolParam],
|
tools: list[ChatCompletionToolParam],
|
||||||
@@ -846,7 +842,18 @@ async def _stream_chat_chunks(
|
|||||||
f"Error in stream (not retrying): {e!s}",
|
f"Error in stream (not retrying): {e!s}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
error_response = StreamError(errorText=str(e))
|
error_code = None
|
||||||
|
error_text = str(e)
|
||||||
|
if _is_region_blocked_error(e):
|
||||||
|
error_code = "MODEL_NOT_AVAILABLE_REGION"
|
||||||
|
error_text = (
|
||||||
|
"This model is not available in your region. "
|
||||||
|
"Please connect via VPN and try again."
|
||||||
|
)
|
||||||
|
error_response = StreamError(
|
||||||
|
errorText=error_text,
|
||||||
|
code=error_code,
|
||||||
|
)
|
||||||
yield error_response
|
yield error_response
|
||||||
yield StreamFinish()
|
yield StreamFinish()
|
||||||
return
|
return
|
||||||
@@ -900,5 +907,4 @@ async def _yield_tool_call(
|
|||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Yielding Tool execution response: {tool_execution_response}")
|
|
||||||
yield tool_execution_response
|
yield tool_execution_response
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
|
|||||||
"find_library_agent": FindLibraryAgentTool(),
|
"find_library_agent": FindLibraryAgentTool(),
|
||||||
"run_agent": RunAgentTool(),
|
"run_agent": RunAgentTool(),
|
||||||
"run_block": RunBlockTool(),
|
"run_block": RunBlockTool(),
|
||||||
"agent_output": AgentOutputTool(),
|
"view_agent_output": AgentOutputTool(),
|
||||||
"search_docs": SearchDocsTool(),
|
"search_docs": SearchDocsTool(),
|
||||||
"get_doc_page": GetDocPageTool(),
|
"get_doc_page": GetDocPageTool(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
from backend.data.understanding import (
|
from backend.data.understanding import (
|
||||||
BusinessUnderstandingInput,
|
BusinessUnderstandingInput,
|
||||||
@@ -59,6 +61,7 @@ and automations for the user's specific needs."""
|
|||||||
"""Requires authentication to store user-specific data."""
|
"""Requires authentication to store user-specific data."""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="add_understanding")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ async def save_agent_to_library(
|
|||||||
library_agents = await library_db.create_library_agent(
|
library_agents = await library_db.create_library_agent(
|
||||||
graph=created_graph,
|
graph=created_graph,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
sensitive_action_safe_mode=True,
|
||||||
create_library_agents_for_sub_graphs=False,
|
create_library_agents_for_sub_graphs=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import re
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
@@ -103,7 +104,7 @@ class AgentOutputTool(BaseTool):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "agent_output"
|
return "view_agent_output"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
@@ -328,6 +329,7 @@ class AgentOutputTool(BaseTool):
|
|||||||
total_executions=len(available_executions) if available_executions else 1,
|
total_executions=len(available_executions) if available_executions else 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="view_agent_output")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
|
|
||||||
from .agent_generator import (
|
from .agent_generator import (
|
||||||
@@ -78,6 +80,7 @@ class CreateAgentTool(BaseTool):
|
|||||||
"required": ["description"],
|
"required": ["description"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="create_agent")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
|
|
||||||
from .agent_generator import (
|
from .agent_generator import (
|
||||||
@@ -85,6 +87,7 @@ class EditAgentTool(BaseTool):
|
|||||||
"required": ["agent_id", "changes"],
|
"required": ["agent_id", "changes"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="edit_agent")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
|
|
||||||
from .agent_search import search_agents
|
from .agent_search import search_agents
|
||||||
@@ -35,6 +37,7 @@ class FindAgentTool(BaseTool):
|
|||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="find_agent")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self, user_id: str | None, session: ChatSession, **kwargs
|
self, user_id: str | None, session: ChatSession, **kwargs
|
||||||
) -> ToolResponseBase:
|
) -> ToolResponseBase:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
from prisma.enums import ContentType
|
from prisma.enums import ContentType
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
@@ -55,6 +56,7 @@ class FindBlockTool(BaseTool):
|
|||||||
def requires_auth(self) -> bool:
|
def requires_auth(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="find_block")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
|
|
||||||
from .agent_search import search_agents
|
from .agent_search import search_agents
|
||||||
@@ -41,6 +43,7 @@ class FindLibraryAgentTool(BaseTool):
|
|||||||
def requires_auth(self) -> bool:
|
def requires_auth(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="find_library_agent")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self, user_id: str | None, session: ChatSession, **kwargs
|
self, user_id: str | None, session: ChatSession, **kwargs
|
||||||
) -> ToolResponseBase:
|
) -> ToolResponseBase:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
from backend.api.features.chat.tools.base import BaseTool
|
from backend.api.features.chat.tools.base import BaseTool
|
||||||
from backend.api.features.chat.tools.models import (
|
from backend.api.features.chat.tools.models import (
|
||||||
@@ -71,6 +73,7 @@ class GetDocPageTool(BaseTool):
|
|||||||
url_path = path.rsplit(".", 1)[0] if "." in path else path
|
url_path = path.rsplit(".", 1)[0] if "." in path else path
|
||||||
return f"{DOCS_BASE_URL}/{url_path}"
|
return f"{DOCS_BASE_URL}/{url_path}"
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="get_doc_page")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from backend.api.features.chat.config import ChatConfig
|
from backend.api.features.chat.config import ChatConfig
|
||||||
@@ -32,7 +33,7 @@ from .models import (
|
|||||||
UserReadiness,
|
UserReadiness,
|
||||||
)
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
check_user_has_required_credentials,
|
build_missing_credentials_from_graph,
|
||||||
extract_credentials_from_schema,
|
extract_credentials_from_schema,
|
||||||
fetch_graph_from_store_slug,
|
fetch_graph_from_store_slug,
|
||||||
get_or_create_library_agent,
|
get_or_create_library_agent,
|
||||||
@@ -154,6 +155,7 @@ class RunAgentTool(BaseTool):
|
|||||||
"""All operations require authentication."""
|
"""All operations require authentication."""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="run_agent")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
@@ -235,15 +237,13 @@ class RunAgentTool(BaseTool):
|
|||||||
# Return credentials needed response with input data info
|
# Return credentials needed response with input data info
|
||||||
# The UI handles credential setup automatically, so the message
|
# The UI handles credential setup automatically, so the message
|
||||||
# focuses on asking about input data
|
# focuses on asking about input data
|
||||||
credentials = extract_credentials_from_schema(
|
requirements_creds_dict = build_missing_credentials_from_graph(
|
||||||
graph.credentials_input_schema
|
graph, None
|
||||||
)
|
)
|
||||||
missing_creds_check = await check_user_has_required_credentials(
|
missing_credentials_dict = build_missing_credentials_from_graph(
|
||||||
user_id, credentials
|
graph, graph_credentials
|
||||||
)
|
)
|
||||||
missing_credentials_dict = {
|
requirements_creds_list = list(requirements_creds_dict.values())
|
||||||
c.id: c.model_dump() for c in missing_creds_check
|
|
||||||
}
|
|
||||||
|
|
||||||
return SetupRequirementsResponse(
|
return SetupRequirementsResponse(
|
||||||
message=self._build_inputs_message(graph, MSG_WHAT_VALUES_TO_USE),
|
message=self._build_inputs_message(graph, MSG_WHAT_VALUES_TO_USE),
|
||||||
@@ -257,7 +257,7 @@ class RunAgentTool(BaseTool):
|
|||||||
ready_to_run=False,
|
ready_to_run=False,
|
||||||
),
|
),
|
||||||
requirements={
|
requirements={
|
||||||
"credentials": [c.model_dump() for c in credentials],
|
"credentials": requirements_creds_list,
|
||||||
"inputs": self._get_inputs_list(graph.input_schema),
|
"inputs": self._get_inputs_list(graph.input_schema),
|
||||||
"execution_modes": self._get_execution_modes(graph),
|
"execution_modes": self._get_execution_modes(graph),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
from backend.data.block import get_block
|
from backend.data.block import get_block
|
||||||
from backend.data.execution import ExecutionContext
|
from backend.data.execution import ExecutionContext
|
||||||
@@ -20,6 +22,7 @@ from .models import (
|
|||||||
ToolResponseBase,
|
ToolResponseBase,
|
||||||
UserReadiness,
|
UserReadiness,
|
||||||
)
|
)
|
||||||
|
from .utils import build_missing_credentials_from_field_info
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -127,6 +130,7 @@ class RunBlockTool(BaseTool):
|
|||||||
|
|
||||||
return matched_credentials, missing_credentials
|
return matched_credentials, missing_credentials
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="run_block")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
@@ -186,7 +190,11 @@ class RunBlockTool(BaseTool):
|
|||||||
|
|
||||||
if missing_credentials:
|
if missing_credentials:
|
||||||
# Return setup requirements response with missing credentials
|
# Return setup requirements response with missing credentials
|
||||||
missing_creds_dict = {c.id: c.model_dump() for c in missing_credentials}
|
credentials_fields_info = block.input_schema.get_credentials_fields_info()
|
||||||
|
missing_creds_dict = build_missing_credentials_from_field_info(
|
||||||
|
credentials_fields_info, set(matched_credentials.keys())
|
||||||
|
)
|
||||||
|
missing_creds_list = list(missing_creds_dict.values())
|
||||||
|
|
||||||
return SetupRequirementsResponse(
|
return SetupRequirementsResponse(
|
||||||
message=(
|
message=(
|
||||||
@@ -203,7 +211,7 @@ class RunBlockTool(BaseTool):
|
|||||||
ready_to_run=False,
|
ready_to_run=False,
|
||||||
),
|
),
|
||||||
requirements={
|
requirements={
|
||||||
"credentials": [c.model_dump() for c in missing_credentials],
|
"credentials": missing_creds_list,
|
||||||
"inputs": self._get_inputs_list(block),
|
"inputs": self._get_inputs_list(block),
|
||||||
"execution_modes": ["immediate"],
|
"execution_modes": ["immediate"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from langfuse import observe
|
||||||
from prisma.enums import ContentType
|
from prisma.enums import ContentType
|
||||||
|
|
||||||
from backend.api.features.chat.model import ChatSession
|
from backend.api.features.chat.model import ChatSession
|
||||||
@@ -87,6 +88,7 @@ class SearchDocsTool(BaseTool):
|
|||||||
url_path = path.rsplit(".", 1)[0] if "." in path else path
|
url_path = path.rsplit(".", 1)[0] if "." in path else path
|
||||||
return f"{DOCS_BASE_URL}/{url_path}"
|
return f"{DOCS_BASE_URL}/{url_path}"
|
||||||
|
|
||||||
|
@observe(as_type="tool", name="search_docs")
|
||||||
async def _execute(
|
async def _execute(
|
||||||
self,
|
self,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from backend.api.features.library import model as library_model
|
|||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.data import graph as graph_db
|
from backend.data import graph as graph_db
|
||||||
from backend.data.graph import GraphModel
|
from backend.data.graph import GraphModel
|
||||||
from backend.data.model import CredentialsMetaInput
|
from backend.data.model import CredentialsFieldInfo, CredentialsMetaInput
|
||||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||||
from backend.util.exceptions import NotFoundError
|
from backend.util.exceptions import NotFoundError
|
||||||
|
|
||||||
@@ -89,6 +89,59 @@ def extract_credentials_from_schema(
|
|||||||
return credentials
|
return credentials
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_missing_credential(
|
||||||
|
field_key: str, field_info: CredentialsFieldInfo
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert credential field info into a serializable dict that preserves all supported
|
||||||
|
credential types (e.g., api_key + oauth2) so the UI can offer multiple options.
|
||||||
|
"""
|
||||||
|
supported_types = sorted(field_info.supported_types)
|
||||||
|
provider = next(iter(field_info.provider), "unknown")
|
||||||
|
scopes = sorted(field_info.required_scopes or [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": field_key,
|
||||||
|
"title": field_key.replace("_", " ").title(),
|
||||||
|
"provider": provider,
|
||||||
|
"provider_name": provider.replace("_", " ").title(),
|
||||||
|
"type": supported_types[0] if supported_types else "api_key",
|
||||||
|
"types": supported_types,
|
||||||
|
"scopes": scopes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_missing_credentials_from_graph(
|
||||||
|
graph: GraphModel, matched_credentials: dict[str, CredentialsMetaInput] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build a missing_credentials mapping from a graph's aggregated credentials inputs,
|
||||||
|
preserving all supported credential types for each field.
|
||||||
|
"""
|
||||||
|
matched_keys = set(matched_credentials.keys()) if matched_credentials else set()
|
||||||
|
aggregated_fields = graph.aggregate_credentials_inputs()
|
||||||
|
|
||||||
|
return {
|
||||||
|
field_key: _serialize_missing_credential(field_key, field_info)
|
||||||
|
for field_key, (field_info, _node_fields) in aggregated_fields.items()
|
||||||
|
if field_key not in matched_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_missing_credentials_from_field_info(
|
||||||
|
credential_fields: dict[str, CredentialsFieldInfo],
|
||||||
|
matched_keys: set[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build missing_credentials mapping from a simple credentials field info dictionary.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
field_key: _serialize_missing_credential(field_key, field_info)
|
||||||
|
for field_key, field_info in credential_fields.items()
|
||||||
|
if field_key not in matched_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def extract_credentials_as_dict(
|
def extract_credentials_as_dict(
|
||||||
credentials_input_schema: dict[str, Any] | None,
|
credentials_input_schema: dict[str, Any] | None,
|
||||||
) -> dict[str, CredentialsMetaInput]:
|
) -> dict[str, CredentialsMetaInput]:
|
||||||
|
|||||||
@@ -401,27 +401,11 @@ async def add_generated_agent_image(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _initialize_graph_settings(graph: graph_db.GraphModel) -> GraphSettings:
|
|
||||||
"""
|
|
||||||
Initialize GraphSettings based on graph content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
graph: The graph to analyze
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
GraphSettings with appropriate human_in_the_loop_safe_mode value
|
|
||||||
"""
|
|
||||||
if graph.has_human_in_the_loop:
|
|
||||||
# Graph has HITL blocks - set safe mode to True by default
|
|
||||||
return GraphSettings(human_in_the_loop_safe_mode=True)
|
|
||||||
else:
|
|
||||||
# Graph has no HITL blocks - keep None
|
|
||||||
return GraphSettings(human_in_the_loop_safe_mode=None)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_library_agent(
|
async def create_library_agent(
|
||||||
graph: graph_db.GraphModel,
|
graph: graph_db.GraphModel,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
hitl_safe_mode: bool = True,
|
||||||
|
sensitive_action_safe_mode: bool = False,
|
||||||
create_library_agents_for_sub_graphs: bool = True,
|
create_library_agents_for_sub_graphs: bool = True,
|
||||||
) -> list[library_model.LibraryAgent]:
|
) -> list[library_model.LibraryAgent]:
|
||||||
"""
|
"""
|
||||||
@@ -430,6 +414,8 @@ async def create_library_agent(
|
|||||||
Args:
|
Args:
|
||||||
agent: The agent/Graph to add to the library.
|
agent: The agent/Graph to add to the library.
|
||||||
user_id: The user to whom the agent will be added.
|
user_id: The user to whom the agent will be added.
|
||||||
|
hitl_safe_mode: Whether HITL blocks require manual review (default True).
|
||||||
|
sensitive_action_safe_mode: Whether sensitive action blocks require review.
|
||||||
create_library_agents_for_sub_graphs: If True, creates LibraryAgent records for sub-graphs as well.
|
create_library_agents_for_sub_graphs: If True, creates LibraryAgent records for sub-graphs as well.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -465,7 +451,11 @@ async def create_library_agent(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
settings=SafeJson(
|
settings=SafeJson(
|
||||||
_initialize_graph_settings(graph_entry).model_dump()
|
GraphSettings.from_graph(
|
||||||
|
graph_entry,
|
||||||
|
hitl_safe_mode=hitl_safe_mode,
|
||||||
|
sensitive_action_safe_mode=sensitive_action_safe_mode,
|
||||||
|
).model_dump()
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
include=library_agent_include(
|
include=library_agent_include(
|
||||||
@@ -627,33 +617,6 @@ async def update_library_agent(
|
|||||||
raise DatabaseError("Failed to update library agent") from e
|
raise DatabaseError("Failed to update library agent") from e
|
||||||
|
|
||||||
|
|
||||||
async def update_library_agent_settings(
|
|
||||||
user_id: str,
|
|
||||||
agent_id: str,
|
|
||||||
settings: GraphSettings,
|
|
||||||
) -> library_model.LibraryAgent:
|
|
||||||
"""
|
|
||||||
Updates the settings for a specific LibraryAgent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The owner of the LibraryAgent.
|
|
||||||
agent_id: The ID of the LibraryAgent to update.
|
|
||||||
settings: New GraphSettings to apply.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The updated LibraryAgent.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotFoundError: If the specified LibraryAgent does not exist.
|
|
||||||
DatabaseError: If there's an error in the update operation.
|
|
||||||
"""
|
|
||||||
return await update_library_agent(
|
|
||||||
library_agent_id=agent_id,
|
|
||||||
user_id=user_id,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_library_agent(
|
async def delete_library_agent(
|
||||||
library_agent_id: str, user_id: str, soft_delete: bool = True
|
library_agent_id: str, user_id: str, soft_delete: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -838,7 +801,7 @@ async def add_store_agent_to_library(
|
|||||||
"isCreatedByUser": False,
|
"isCreatedByUser": False,
|
||||||
"useGraphIsActiveVersion": False,
|
"useGraphIsActiveVersion": False,
|
||||||
"settings": SafeJson(
|
"settings": SafeJson(
|
||||||
_initialize_graph_settings(graph_model).model_dump()
|
GraphSettings.from_graph(graph_model).model_dump()
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
include=library_agent_include(
|
include=library_agent_include(
|
||||||
@@ -1228,8 +1191,15 @@ async def fork_library_agent(
|
|||||||
)
|
)
|
||||||
new_graph = await on_graph_activate(new_graph, user_id=user_id)
|
new_graph = await on_graph_activate(new_graph, user_id=user_id)
|
||||||
|
|
||||||
# Create a library agent for the new graph
|
# Create a library agent for the new graph, preserving safe mode settings
|
||||||
return (await create_library_agent(new_graph, user_id))[0]
|
return (
|
||||||
|
await create_library_agent(
|
||||||
|
new_graph,
|
||||||
|
user_id,
|
||||||
|
hitl_safe_mode=original_agent.settings.human_in_the_loop_safe_mode,
|
||||||
|
sensitive_action_safe_mode=original_agent.settings.sensitive_action_safe_mode,
|
||||||
|
)
|
||||||
|
)[0]
|
||||||
except prisma.errors.PrismaError as e:
|
except prisma.errors.PrismaError as e:
|
||||||
logger.error(f"Database error cloning library agent: {e}")
|
logger.error(f"Database error cloning library agent: {e}")
|
||||||
raise DatabaseError("Failed to fork library agent") from e
|
raise DatabaseError("Failed to fork library agent") from e
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
has_external_trigger: bool = pydantic.Field(
|
has_external_trigger: bool = pydantic.Field(
|
||||||
description="Whether the agent has an external trigger (e.g. webhook) node"
|
description="Whether the agent has an external trigger (e.g. webhook) node"
|
||||||
)
|
)
|
||||||
|
has_human_in_the_loop: bool = pydantic.Field(
|
||||||
|
description="Whether the agent has human-in-the-loop blocks"
|
||||||
|
)
|
||||||
|
has_sensitive_action: bool = pydantic.Field(
|
||||||
|
description="Whether the agent has sensitive action blocks"
|
||||||
|
)
|
||||||
trigger_setup_info: Optional[GraphTriggerInfo] = None
|
trigger_setup_info: Optional[GraphTriggerInfo] = None
|
||||||
|
|
||||||
# Indicates whether there's a new output (based on recent runs)
|
# Indicates whether there's a new output (based on recent runs)
|
||||||
@@ -180,6 +186,8 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
graph.credentials_input_schema if sub_graphs is not None else None
|
graph.credentials_input_schema if sub_graphs is not None else None
|
||||||
),
|
),
|
||||||
has_external_trigger=graph.has_external_trigger,
|
has_external_trigger=graph.has_external_trigger,
|
||||||
|
has_human_in_the_loop=graph.has_human_in_the_loop,
|
||||||
|
has_sensitive_action=graph.has_sensitive_action,
|
||||||
trigger_setup_info=graph.trigger_setup_info,
|
trigger_setup_info=graph.trigger_setup_info,
|
||||||
new_output=new_output,
|
new_output=new_output,
|
||||||
can_access_graph=can_access_graph,
|
can_access_graph=can_access_graph,
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ async def test_get_library_agents_success(
|
|||||||
output_schema={"type": "object", "properties": {}},
|
output_schema={"type": "object", "properties": {}},
|
||||||
credentials_input_schema={"type": "object", "properties": {}},
|
credentials_input_schema={"type": "object", "properties": {}},
|
||||||
has_external_trigger=False,
|
has_external_trigger=False,
|
||||||
|
has_human_in_the_loop=False,
|
||||||
|
has_sensitive_action=False,
|
||||||
status=library_model.LibraryAgentStatus.COMPLETED,
|
status=library_model.LibraryAgentStatus.COMPLETED,
|
||||||
recommended_schedule_cron=None,
|
recommended_schedule_cron=None,
|
||||||
new_output=False,
|
new_output=False,
|
||||||
@@ -75,6 +77,8 @@ async def test_get_library_agents_success(
|
|||||||
output_schema={"type": "object", "properties": {}},
|
output_schema={"type": "object", "properties": {}},
|
||||||
credentials_input_schema={"type": "object", "properties": {}},
|
credentials_input_schema={"type": "object", "properties": {}},
|
||||||
has_external_trigger=False,
|
has_external_trigger=False,
|
||||||
|
has_human_in_the_loop=False,
|
||||||
|
has_sensitive_action=False,
|
||||||
status=library_model.LibraryAgentStatus.COMPLETED,
|
status=library_model.LibraryAgentStatus.COMPLETED,
|
||||||
recommended_schedule_cron=None,
|
recommended_schedule_cron=None,
|
||||||
new_output=False,
|
new_output=False,
|
||||||
@@ -150,6 +154,8 @@ async def test_get_favorite_library_agents_success(
|
|||||||
output_schema={"type": "object", "properties": {}},
|
output_schema={"type": "object", "properties": {}},
|
||||||
credentials_input_schema={"type": "object", "properties": {}},
|
credentials_input_schema={"type": "object", "properties": {}},
|
||||||
has_external_trigger=False,
|
has_external_trigger=False,
|
||||||
|
has_human_in_the_loop=False,
|
||||||
|
has_sensitive_action=False,
|
||||||
status=library_model.LibraryAgentStatus.COMPLETED,
|
status=library_model.LibraryAgentStatus.COMPLETED,
|
||||||
recommended_schedule_cron=None,
|
recommended_schedule_cron=None,
|
||||||
new_output=False,
|
new_output=False,
|
||||||
@@ -218,6 +224,8 @@ def test_add_agent_to_library_success(
|
|||||||
output_schema={"type": "object", "properties": {}},
|
output_schema={"type": "object", "properties": {}},
|
||||||
credentials_input_schema={"type": "object", "properties": {}},
|
credentials_input_schema={"type": "object", "properties": {}},
|
||||||
has_external_trigger=False,
|
has_external_trigger=False,
|
||||||
|
has_human_in_the_loop=False,
|
||||||
|
has_sensitive_action=False,
|
||||||
status=library_model.LibraryAgentStatus.COMPLETED,
|
status=library_model.LibraryAgentStatus.COMPLETED,
|
||||||
new_output=False,
|
new_output=False,
|
||||||
can_access_graph=True,
|
can_access_graph=True,
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ async def store_content_embedding(
|
|||||||
|
|
||||||
# Upsert the embedding
|
# Upsert the embedding
|
||||||
# WHERE clause in DO UPDATE prevents PostgreSQL 15 bug with NULLS NOT DISTINCT
|
# 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(
|
await execute_raw_with_schema(
|
||||||
"""
|
"""
|
||||||
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
|
INSERT INTO {schema_prefix}"UnifiedContentEmbedding" (
|
||||||
@@ -177,7 +178,6 @@ async def store_content_embedding(
|
|||||||
searchable_text,
|
searchable_text,
|
||||||
metadata_json,
|
metadata_json,
|
||||||
client=client,
|
client=client,
|
||||||
set_public_search_path=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Stored embedding for {content_type}:{content_id}")
|
logger.info(f"Stored embedding for {content_type}:{content_id}")
|
||||||
@@ -236,7 +236,6 @@ async def get_content_embedding(
|
|||||||
content_type,
|
content_type,
|
||||||
content_id,
|
content_id,
|
||||||
user_id,
|
user_id,
|
||||||
set_public_search_path=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result and len(result) > 0:
|
if result and len(result) > 0:
|
||||||
@@ -871,31 +870,45 @@ async def semantic_search(
|
|||||||
# Add content type parameters and build placeholders dynamically
|
# Add content type parameters and build placeholders dynamically
|
||||||
content_type_start_idx = len(params) + 1
|
content_type_start_idx = len(params) + 1
|
||||||
content_type_placeholders = ", ".join(
|
content_type_placeholders = ", ".join(
|
||||||
f'${content_type_start_idx + i}::{{{{schema_prefix}}}}"ContentType"'
|
"$" + str(content_type_start_idx + i) + '::{schema_prefix}"ContentType"'
|
||||||
for i in range(len(content_types))
|
for i in range(len(content_types))
|
||||||
)
|
)
|
||||||
params.extend([ct.value for ct in content_types])
|
params.extend([ct.value for ct in content_types])
|
||||||
|
|
||||||
sql = f"""
|
# Build min_similarity param index before appending
|
||||||
|
min_similarity_idx = len(params) + 1
|
||||||
|
params.append(min_similarity)
|
||||||
|
|
||||||
|
# Use unqualified ::vector and <=> operator - pgvector is in search_path on all environments
|
||||||
|
sql = (
|
||||||
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
"contentId" as content_id,
|
"contentId" as content_id,
|
||||||
"contentType" as content_type,
|
"contentType" as content_type,
|
||||||
"searchableText" as searchable_text,
|
"searchableText" as searchable_text,
|
||||||
metadata,
|
metadata,
|
||||||
1 - (embedding <=> '{embedding_str}'::vector) as similarity
|
1 - (embedding <=> '"""
|
||||||
FROM {{{{schema_prefix}}}}"UnifiedContentEmbedding"
|
+ embedding_str
|
||||||
WHERE "contentType" IN ({content_type_placeholders})
|
+ """'::vector) as similarity
|
||||||
{user_filter}
|
FROM {schema_prefix}"UnifiedContentEmbedding"
|
||||||
AND 1 - (embedding <=> '{embedding_str}'::vector) >= ${len(params) + 1}
|
WHERE "contentType" IN ("""
|
||||||
|
+ content_type_placeholders
|
||||||
|
+ """)
|
||||||
|
"""
|
||||||
|
+ user_filter
|
||||||
|
+ """
|
||||||
|
AND 1 - (embedding <=> '"""
|
||||||
|
+ embedding_str
|
||||||
|
+ """'::vector) >= $"""
|
||||||
|
+ str(min_similarity_idx)
|
||||||
|
+ """
|
||||||
ORDER BY similarity DESC
|
ORDER BY similarity DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
"""
|
"""
|
||||||
params.append(min_similarity)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await query_raw_with_schema(
|
results = await query_raw_with_schema(sql, *params)
|
||||||
sql, *params, set_public_search_path=True
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"content_id": row["content_id"],
|
"content_id": row["content_id"],
|
||||||
@@ -922,31 +935,41 @@ async def semantic_search(
|
|||||||
# Add content type parameters and build placeholders dynamically
|
# Add content type parameters and build placeholders dynamically
|
||||||
content_type_start_idx = len(params_lexical) + 1
|
content_type_start_idx = len(params_lexical) + 1
|
||||||
content_type_placeholders_lexical = ", ".join(
|
content_type_placeholders_lexical = ", ".join(
|
||||||
f'${content_type_start_idx + i}::{{{{schema_prefix}}}}"ContentType"'
|
"$" + str(content_type_start_idx + i) + '::{schema_prefix}"ContentType"'
|
||||||
for i in range(len(content_types))
|
for i in range(len(content_types))
|
||||||
)
|
)
|
||||||
params_lexical.extend([ct.value for ct in content_types])
|
params_lexical.extend([ct.value for ct in content_types])
|
||||||
|
|
||||||
sql_lexical = f"""
|
# Build query param index before appending
|
||||||
|
query_param_idx = len(params_lexical) + 1
|
||||||
|
params_lexical.append(f"%{query}%")
|
||||||
|
|
||||||
|
# Use regular string (not f-string) for template to preserve {schema_prefix} placeholders
|
||||||
|
sql_lexical = (
|
||||||
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
"contentId" as content_id,
|
"contentId" as content_id,
|
||||||
"contentType" as content_type,
|
"contentType" as content_type,
|
||||||
"searchableText" as searchable_text,
|
"searchableText" as searchable_text,
|
||||||
metadata,
|
metadata,
|
||||||
0.0 as similarity
|
0.0 as similarity
|
||||||
FROM {{{{schema_prefix}}}}"UnifiedContentEmbedding"
|
FROM {schema_prefix}"UnifiedContentEmbedding"
|
||||||
WHERE "contentType" IN ({content_type_placeholders_lexical})
|
WHERE "contentType" IN ("""
|
||||||
{user_filter}
|
+ content_type_placeholders_lexical
|
||||||
AND "searchableText" ILIKE ${len(params_lexical) + 1}
|
+ """)
|
||||||
|
"""
|
||||||
|
+ user_filter
|
||||||
|
+ """
|
||||||
|
AND "searchableText" ILIKE $"""
|
||||||
|
+ str(query_param_idx)
|
||||||
|
+ """
|
||||||
ORDER BY "updatedAt" DESC
|
ORDER BY "updatedAt" DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
"""
|
"""
|
||||||
params_lexical.append(f"%{query}%")
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await query_raw_with_schema(
|
results = await query_raw_with_schema(sql_lexical, *params_lexical)
|
||||||
sql_lexical, *params_lexical, set_public_search_path=True
|
|
||||||
)
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"content_id": row["content_id"],
|
"content_id": row["content_id"],
|
||||||
|
|||||||
@@ -155,18 +155,14 @@ async def test_store_embedding_success(mocker):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
# execute_raw is called twice: once for SET search_path, once for INSERT
|
# execute_raw is called once for INSERT (no separate SET search_path needed)
|
||||||
assert mock_client.execute_raw.call_count == 2
|
assert mock_client.execute_raw.call_count == 1
|
||||||
|
|
||||||
# First call: SET search_path
|
# Verify the INSERT query with the actual data
|
||||||
first_call_args = mock_client.execute_raw.call_args_list[0][0]
|
call_args = mock_client.execute_raw.call_args_list[0][0]
|
||||||
assert "SET search_path" in first_call_args[0]
|
assert "test-version-id" in call_args
|
||||||
|
assert "[0.1,0.2,0.3]" in call_args
|
||||||
# Second call: INSERT query with the actual data
|
assert None in call_args # userId should be None for store agents
|
||||||
second_call_args = mock_client.execute_raw.call_args_list[1][0]
|
|
||||||
assert "test-version-id" in second_call_args
|
|
||||||
assert "[0.1,0.2,0.3]" in second_call_args
|
|
||||||
assert None in second_call_args # userId should be None for store agents
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio(loop_scope="session")
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from prisma.enums import ContentType
|
from prisma.enums import ContentType
|
||||||
from rank_bm25 import BM25Okapi
|
from rank_bm25 import BM25Okapi # type: ignore[import-untyped]
|
||||||
|
|
||||||
from backend.api.features.store.embeddings import (
|
from backend.api.features.store.embeddings import (
|
||||||
EMBEDDING_DIM,
|
EMBEDDING_DIM,
|
||||||
@@ -363,9 +363,7 @@ async def unified_hybrid_search(
|
|||||||
LIMIT {limit_param} OFFSET {offset_param}
|
LIMIT {limit_param} OFFSET {offset_param}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
results = await query_raw_with_schema(
|
results = await query_raw_with_schema(sql_query, *params)
|
||||||
sql_query, *params, set_public_search_path=True
|
|
||||||
)
|
|
||||||
|
|
||||||
total = results[0]["total_count"] if results else 0
|
total = results[0]["total_count"] if results else 0
|
||||||
# Apply BM25 reranking
|
# Apply BM25 reranking
|
||||||
@@ -688,9 +686,7 @@ async def hybrid_search(
|
|||||||
LIMIT {limit_param} OFFSET {offset_param}
|
LIMIT {limit_param} OFFSET {offset_param}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
results = await query_raw_with_schema(
|
results = await query_raw_with_schema(sql_query, *params)
|
||||||
sql_query, *params, set_public_search_path=True
|
|
||||||
)
|
|
||||||
|
|
||||||
total = results[0]["total_count"] if results else 0
|
total = results[0]["total_count"] if results else 0
|
||||||
|
|
||||||
|
|||||||
@@ -761,10 +761,8 @@ async def create_new_graph(
|
|||||||
graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
|
graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
|
||||||
graph.validate_graph(for_run=False)
|
graph.validate_graph(for_run=False)
|
||||||
|
|
||||||
# The return value of the create graph & library function is intentionally not used here,
|
|
||||||
# as the graph already valid and no sub-graphs are returned back.
|
|
||||||
await graph_db.create_graph(graph, user_id=user_id)
|
await graph_db.create_graph(graph, user_id=user_id)
|
||||||
await library_db.create_library_agent(graph, user_id=user_id)
|
await library_db.create_library_agent(graph, user_id)
|
||||||
activated_graph = await on_graph_activate(graph, user_id=user_id)
|
activated_graph = await on_graph_activate(graph, user_id=user_id)
|
||||||
|
|
||||||
if create_graph.source == "builder":
|
if create_graph.source == "builder":
|
||||||
@@ -888,21 +886,19 @@ async def set_graph_active_version(
|
|||||||
async def _update_library_agent_version_and_settings(
|
async def _update_library_agent_version_and_settings(
|
||||||
user_id: str, agent_graph: graph_db.GraphModel
|
user_id: str, agent_graph: graph_db.GraphModel
|
||||||
) -> library_model.LibraryAgent:
|
) -> library_model.LibraryAgent:
|
||||||
# Keep the library agent up to date with the new active version
|
|
||||||
library = await library_db.update_agent_version_in_library(
|
library = await library_db.update_agent_version_in_library(
|
||||||
user_id, agent_graph.id, agent_graph.version
|
user_id, agent_graph.id, agent_graph.version
|
||||||
)
|
)
|
||||||
# If the graph has HITL node, initialize the setting if it's not already set.
|
updated_settings = GraphSettings.from_graph(
|
||||||
if (
|
graph=agent_graph,
|
||||||
agent_graph.has_human_in_the_loop
|
hitl_safe_mode=library.settings.human_in_the_loop_safe_mode,
|
||||||
and library.settings.human_in_the_loop_safe_mode is None
|
sensitive_action_safe_mode=library.settings.sensitive_action_safe_mode,
|
||||||
):
|
)
|
||||||
await library_db.update_library_agent_settings(
|
if updated_settings != library.settings:
|
||||||
|
library = await library_db.update_library_agent(
|
||||||
|
library_agent_id=library.id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
agent_id=library.id,
|
settings=updated_settings,
|
||||||
settings=library.settings.model_copy(
|
|
||||||
update={"human_in_the_loop_safe_mode": True}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return library
|
return library
|
||||||
|
|
||||||
@@ -919,21 +915,18 @@ async def update_graph_settings(
|
|||||||
user_id: Annotated[str, Security(get_user_id)],
|
user_id: Annotated[str, Security(get_user_id)],
|
||||||
) -> GraphSettings:
|
) -> GraphSettings:
|
||||||
"""Update graph settings for the user's library agent."""
|
"""Update graph settings for the user's library agent."""
|
||||||
# Get the library agent for this graph
|
|
||||||
library_agent = await library_db.get_library_agent_by_graph_id(
|
library_agent = await library_db.get_library_agent_by_graph_id(
|
||||||
graph_id=graph_id, user_id=user_id
|
graph_id=graph_id, user_id=user_id
|
||||||
)
|
)
|
||||||
if not library_agent:
|
if not library_agent:
|
||||||
raise HTTPException(404, f"Graph #{graph_id} not found in user's library")
|
raise HTTPException(404, f"Graph #{graph_id} not found in user's library")
|
||||||
|
|
||||||
# Update the library agent settings
|
updated_agent = await library_db.update_library_agent(
|
||||||
updated_agent = await library_db.update_library_agent_settings(
|
library_agent_id=library_agent.id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
agent_id=library_agent.id,
|
|
||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return the updated settings
|
|
||||||
return GraphSettings.model_validate(updated_agent.settings)
|
return GraphSettings.model_validate(updated_agent.settings)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ class AIShortformVideoCreatorBlock(Block):
|
|||||||
)
|
)
|
||||||
frame_rate: int = SchemaField(description="Frame rate of the video", default=60)
|
frame_rate: int = SchemaField(description="Frame rate of the video", default=60)
|
||||||
generation_preset: GenerationPreset = SchemaField(
|
generation_preset: GenerationPreset = SchemaField(
|
||||||
description="Generation preset for visual style - only effects AI generated visuals",
|
description="Generation preset for visual style - only affects AI-generated visuals",
|
||||||
default=GenerationPreset.LEONARDO,
|
default=GenerationPreset.LEONARDO,
|
||||||
placeholder=GenerationPreset.LEONARDO,
|
placeholder=GenerationPreset.LEONARDO,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ Each range you add needs to be a string, with the upper and lower numbers of the
|
|||||||
organization_locations: Optional[list[str]] = SchemaField(
|
organization_locations: Optional[list[str]] = SchemaField(
|
||||||
description="""The location of the company headquarters. You can search across cities, US states, and countries.
|
description="""The location of the company headquarters. You can search across cities, US states, and countries.
|
||||||
|
|
||||||
If a company has several office locations, results are still based on the headquarters location. For example, if you search chicago but a company's HQ location is in boston, any Boston-based companies will not appearch in your search results, even if they match other parameters.
|
If a company has several office locations, results are still based on the headquarters location. For example, if you search chicago but a company's HQ location is in boston, any Boston-based companies will not appear in your search results, even if they match other parameters.
|
||||||
|
|
||||||
To exclude companies based on location, use the organization_not_locations parameter.
|
To exclude companies based on location, use the organization_not_locations parameter.
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Each range you add needs to be a string, with the upper and lower numbers of the
|
|||||||
organization_locations: list[str] = SchemaField(
|
organization_locations: list[str] = SchemaField(
|
||||||
description="""The location of the company headquarters. You can search across cities, US states, and countries.
|
description="""The location of the company headquarters. You can search across cities, US states, and countries.
|
||||||
|
|
||||||
If a company has several office locations, results are still based on the headquarters location. For example, if you search chicago but a company's HQ location is in boston, any Boston-based companies will not appearch in your search results, even if they match other parameters.
|
If a company has several office locations, results are still based on the headquarters location. For example, if you search chicago but a company's HQ location is in boston, any Boston-based companies will not appear in your search results, even if they match other parameters.
|
||||||
|
|
||||||
To exclude companies based on location, use the organization_not_locations parameter.
|
To exclude companies based on location, use the organization_not_locations parameter.
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class StoreValueBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="1ff065e9-88e8-4358-9d82-8dc91f622ba9",
|
id="1ff065e9-88e8-4358-9d82-8dc91f622ba9",
|
||||||
description="This block forwards an input value as output, allowing reuse without change.",
|
description="A basic block that stores and forwards a value throughout workflows, allowing it to be reused without changes across multiple blocks.",
|
||||||
categories={BlockCategory.BASIC},
|
categories={BlockCategory.BASIC},
|
||||||
input_schema=StoreValueBlock.Input,
|
input_schema=StoreValueBlock.Input,
|
||||||
output_schema=StoreValueBlock.Output,
|
output_schema=StoreValueBlock.Output,
|
||||||
@@ -111,7 +111,7 @@ class PrintToConsoleBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="f3b1c1b2-4c4f-4f0d-8d2f-4c4f0d8d2f4c",
|
id="f3b1c1b2-4c4f-4f0d-8d2f-4c4f0d8d2f4c",
|
||||||
description="Print the given text to the console, this is used for a debugging purpose.",
|
description="A debugging block that outputs text to the console for monitoring and troubleshooting workflow execution.",
|
||||||
categories={BlockCategory.BASIC},
|
categories={BlockCategory.BASIC},
|
||||||
input_schema=PrintToConsoleBlock.Input,
|
input_schema=PrintToConsoleBlock.Input,
|
||||||
output_schema=PrintToConsoleBlock.Output,
|
output_schema=PrintToConsoleBlock.Output,
|
||||||
@@ -137,7 +137,7 @@ class NoteBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="cc10ff7b-7753-4ff2-9af6-9399b1a7eddc",
|
id="cc10ff7b-7753-4ff2-9af6-9399b1a7eddc",
|
||||||
description="This block is used to display a sticky note with the given text.",
|
description="A visual annotation block that displays a sticky note in the workflow editor for documentation and organization purposes.",
|
||||||
categories={BlockCategory.BASIC},
|
categories={BlockCategory.BASIC},
|
||||||
input_schema=NoteBlock.Input,
|
input_schema=NoteBlock.Input,
|
||||||
output_schema=NoteBlock.Output,
|
output_schema=NoteBlock.Output,
|
||||||
|
|||||||
659
autogpt_platform/backend/backend/blocks/claude_code.py
Normal file
@@ -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": "<html>Hello World</html>",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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="<html>Hello World</html>",
|
||||||
|
)
|
||||||
|
], # 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)
|
||||||
@@ -159,7 +159,7 @@ class FindInDictionaryBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="0e50422c-6dee-4145-83d6-3a5a392f65de",
|
id="0e50422c-6dee-4145-83d6-3a5a392f65de",
|
||||||
description="Lookup the given key in the input dictionary/object/list and return the value.",
|
description="A block that looks up a value in a dictionary, list, or object by key or index and returns the corresponding value.",
|
||||||
input_schema=FindInDictionaryBlock.Input,
|
input_schema=FindInDictionaryBlock.Input,
|
||||||
output_schema=FindInDictionaryBlock.Output,
|
output_schema=FindInDictionaryBlock.Output,
|
||||||
test_input=[
|
test_input=[
|
||||||
@@ -680,3 +680,58 @@ class ListIsEmptyBlock(Block):
|
|||||||
|
|
||||||
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||||
yield "is_empty", len(input_data.list) == 0
|
yield "is_empty", len(input_data.list) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class ConcatenateListsBlock(Block):
|
||||||
|
class Input(BlockSchemaInput):
|
||||||
|
lists: List[List[Any]] = SchemaField(
|
||||||
|
description="A list of lists to concatenate together. All lists will be combined in order into a single list.",
|
||||||
|
placeholder="e.g., [[1, 2], [3, 4], [5, 6]]",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Output(BlockSchemaOutput):
|
||||||
|
concatenated_list: List[Any] = SchemaField(
|
||||||
|
description="The concatenated list containing all elements from all input lists in order."
|
||||||
|
)
|
||||||
|
error: str = SchemaField(
|
||||||
|
description="Error message if concatenation failed due to invalid input types."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
id="3cf9298b-5817-4141-9d80-7c2cc5199c8e",
|
||||||
|
description="Concatenates multiple lists into a single list. All elements from all input lists are combined in order.",
|
||||||
|
categories={BlockCategory.BASIC},
|
||||||
|
input_schema=ConcatenateListsBlock.Input,
|
||||||
|
output_schema=ConcatenateListsBlock.Output,
|
||||||
|
test_input=[
|
||||||
|
{"lists": [[1, 2, 3], [4, 5, 6]]},
|
||||||
|
{"lists": [["a", "b"], ["c"], ["d", "e", "f"]]},
|
||||||
|
{"lists": [[1, 2], []]},
|
||||||
|
{"lists": []},
|
||||||
|
],
|
||||||
|
test_output=[
|
||||||
|
("concatenated_list", [1, 2, 3, 4, 5, 6]),
|
||||||
|
("concatenated_list", ["a", "b", "c", "d", "e", "f"]),
|
||||||
|
("concatenated_list", [1, 2]),
|
||||||
|
("concatenated_list", []),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
|
||||||
|
concatenated = []
|
||||||
|
for idx, lst in enumerate(input_data.lists):
|
||||||
|
if lst is None:
|
||||||
|
# Skip None values to avoid errors
|
||||||
|
continue
|
||||||
|
if not isinstance(lst, list):
|
||||||
|
# Type validation: each item must be a list
|
||||||
|
# Strings are iterable and would cause extend() to iterate character-by-character
|
||||||
|
# Non-iterable types would raise TypeError
|
||||||
|
yield "error", (
|
||||||
|
f"Invalid input at index {idx}: expected a list, got {type(lst).__name__}. "
|
||||||
|
f"All items in 'lists' must be lists (e.g., [[1, 2], [3, 4]])."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
concatenated.extend(lst)
|
||||||
|
yield "concatenated_list", concatenated
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class GithubCommentBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="a8db4d8d-db1c-4a25-a1b0-416a8c33602b",
|
id="a8db4d8d-db1c-4a25-a1b0-416a8c33602b",
|
||||||
description="This block posts a comment on a specified GitHub issue or pull request.",
|
description="A block that posts comments on GitHub issues or pull requests using the GitHub API.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubCommentBlock.Input,
|
input_schema=GithubCommentBlock.Input,
|
||||||
output_schema=GithubCommentBlock.Output,
|
output_schema=GithubCommentBlock.Output,
|
||||||
@@ -151,7 +151,7 @@ class GithubUpdateCommentBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="b3f4d747-10e3-4e69-8c51-f2be1d99c9a7",
|
id="b3f4d747-10e3-4e69-8c51-f2be1d99c9a7",
|
||||||
description="This block updates a comment on a specified GitHub issue or pull request.",
|
description="A block that updates an existing comment on a GitHub issue or pull request.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubUpdateCommentBlock.Input,
|
input_schema=GithubUpdateCommentBlock.Input,
|
||||||
output_schema=GithubUpdateCommentBlock.Output,
|
output_schema=GithubUpdateCommentBlock.Output,
|
||||||
@@ -249,7 +249,7 @@ class GithubListCommentsBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="c4b5fb63-0005-4a11-b35a-0c2467bd6b59",
|
id="c4b5fb63-0005-4a11-b35a-0c2467bd6b59",
|
||||||
description="This block lists all comments for a specified GitHub issue or pull request.",
|
description="A block that retrieves all comments from a GitHub issue or pull request, including comment metadata and content.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubListCommentsBlock.Input,
|
input_schema=GithubListCommentsBlock.Input,
|
||||||
output_schema=GithubListCommentsBlock.Output,
|
output_schema=GithubListCommentsBlock.Output,
|
||||||
@@ -363,7 +363,7 @@ class GithubMakeIssueBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="691dad47-f494-44c3-a1e8-05b7990f2dab",
|
id="691dad47-f494-44c3-a1e8-05b7990f2dab",
|
||||||
description="This block creates a new issue on a specified GitHub repository.",
|
description="A block that creates new issues on GitHub repositories with a title and body content.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubMakeIssueBlock.Input,
|
input_schema=GithubMakeIssueBlock.Input,
|
||||||
output_schema=GithubMakeIssueBlock.Output,
|
output_schema=GithubMakeIssueBlock.Output,
|
||||||
@@ -433,7 +433,7 @@ class GithubReadIssueBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="6443c75d-032a-4772-9c08-230c707c8acc",
|
id="6443c75d-032a-4772-9c08-230c707c8acc",
|
||||||
description="This block reads the body, title, and user of a specified GitHub issue.",
|
description="A block that retrieves information about a specific GitHub issue, including its title, body content, and creator.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubReadIssueBlock.Input,
|
input_schema=GithubReadIssueBlock.Input,
|
||||||
output_schema=GithubReadIssueBlock.Output,
|
output_schema=GithubReadIssueBlock.Output,
|
||||||
@@ -510,7 +510,7 @@ class GithubListIssuesBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="c215bfd7-0e57-4573-8f8c-f7d4963dcd74",
|
id="c215bfd7-0e57-4573-8f8c-f7d4963dcd74",
|
||||||
description="This block lists all issues for a specified GitHub repository.",
|
description="A block that retrieves a list of issues from a GitHub repository with their titles and URLs.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubListIssuesBlock.Input,
|
input_schema=GithubListIssuesBlock.Input,
|
||||||
output_schema=GithubListIssuesBlock.Output,
|
output_schema=GithubListIssuesBlock.Output,
|
||||||
@@ -597,7 +597,7 @@ class GithubAddLabelBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="98bd6b77-9506-43d5-b669-6b9733c4b1f1",
|
id="98bd6b77-9506-43d5-b669-6b9733c4b1f1",
|
||||||
description="This block adds a label to a specified GitHub issue or pull request.",
|
description="A block that adds a label to a GitHub issue or pull request for categorization and organization.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubAddLabelBlock.Input,
|
input_schema=GithubAddLabelBlock.Input,
|
||||||
output_schema=GithubAddLabelBlock.Output,
|
output_schema=GithubAddLabelBlock.Output,
|
||||||
@@ -657,7 +657,7 @@ class GithubRemoveLabelBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="78f050c5-3e3a-48c0-9e5b-ef1ceca5589c",
|
id="78f050c5-3e3a-48c0-9e5b-ef1ceca5589c",
|
||||||
description="This block removes a label from a specified GitHub issue or pull request.",
|
description="A block that removes a label from a GitHub issue or pull request.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubRemoveLabelBlock.Input,
|
input_schema=GithubRemoveLabelBlock.Input,
|
||||||
output_schema=GithubRemoveLabelBlock.Output,
|
output_schema=GithubRemoveLabelBlock.Output,
|
||||||
@@ -720,7 +720,7 @@ class GithubAssignIssueBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="90507c72-b0ff-413a-886a-23bbbd66f542",
|
id="90507c72-b0ff-413a-886a-23bbbd66f542",
|
||||||
description="This block assigns a user to a specified GitHub issue.",
|
description="A block that assigns a GitHub user to an issue for task ownership and tracking.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubAssignIssueBlock.Input,
|
input_schema=GithubAssignIssueBlock.Input,
|
||||||
output_schema=GithubAssignIssueBlock.Output,
|
output_schema=GithubAssignIssueBlock.Output,
|
||||||
@@ -786,7 +786,7 @@ class GithubUnassignIssueBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="d154002a-38f4-46c2-962d-2488f2b05ece",
|
id="d154002a-38f4-46c2-962d-2488f2b05ece",
|
||||||
description="This block unassigns a user from a specified GitHub issue.",
|
description="A block that removes a user's assignment from a GitHub issue.",
|
||||||
categories={BlockCategory.DEVELOPER_TOOLS},
|
categories={BlockCategory.DEVELOPER_TOOLS},
|
||||||
input_schema=GithubUnassignIssueBlock.Input,
|
input_schema=GithubUnassignIssueBlock.Input,
|
||||||
output_schema=GithubUnassignIssueBlock.Output,
|
output_schema=GithubUnassignIssueBlock.Output,
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ class GmailReadBlock(GmailBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="25310c70-b89b-43ba-b25c-4dfa7e2a481c",
|
id="25310c70-b89b-43ba-b25c-4dfa7e2a481c",
|
||||||
description="This block reads emails from Gmail.",
|
description="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.",
|
||||||
categories={BlockCategory.COMMUNICATION},
|
categories={BlockCategory.COMMUNICATION},
|
||||||
disabled=not GOOGLE_OAUTH_IS_CONFIGURED,
|
disabled=not GOOGLE_OAUTH_IS_CONFIGURED,
|
||||||
input_schema=GmailReadBlock.Input,
|
input_schema=GmailReadBlock.Input,
|
||||||
@@ -743,7 +743,7 @@ class GmailListLabelsBlock(GmailBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="3e1c2c1c-c689-4520-b956-1f3bf4e02bb7",
|
id="3e1c2c1c-c689-4520-b956-1f3bf4e02bb7",
|
||||||
description="This block lists all labels in Gmail.",
|
description="A block that retrieves all labels (categories) from a Gmail account for organizing and categorizing emails.",
|
||||||
categories={BlockCategory.COMMUNICATION},
|
categories={BlockCategory.COMMUNICATION},
|
||||||
input_schema=GmailListLabelsBlock.Input,
|
input_schema=GmailListLabelsBlock.Input,
|
||||||
output_schema=GmailListLabelsBlock.Output,
|
output_schema=GmailListLabelsBlock.Output,
|
||||||
@@ -807,7 +807,7 @@ class GmailAddLabelBlock(GmailBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="f884b2fb-04f4-4265-9658-14f433926ac9",
|
id="f884b2fb-04f4-4265-9658-14f433926ac9",
|
||||||
description="This block adds a label to a Gmail message.",
|
description="A block that adds a label to a specific email message in Gmail, creating the label if it doesn't exist.",
|
||||||
categories={BlockCategory.COMMUNICATION},
|
categories={BlockCategory.COMMUNICATION},
|
||||||
input_schema=GmailAddLabelBlock.Input,
|
input_schema=GmailAddLabelBlock.Input,
|
||||||
output_schema=GmailAddLabelBlock.Output,
|
output_schema=GmailAddLabelBlock.Output,
|
||||||
@@ -893,7 +893,7 @@ class GmailRemoveLabelBlock(GmailBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="0afc0526-aba1-4b2b-888e-a22b7c3f359d",
|
id="0afc0526-aba1-4b2b-888e-a22b7c3f359d",
|
||||||
description="This block removes a label from a Gmail message.",
|
description="A block that removes a label from a specific email message in a Gmail account.",
|
||||||
categories={BlockCategory.COMMUNICATION},
|
categories={BlockCategory.COMMUNICATION},
|
||||||
input_schema=GmailRemoveLabelBlock.Input,
|
input_schema=GmailRemoveLabelBlock.Input,
|
||||||
output_schema=GmailRemoveLabelBlock.Output,
|
output_schema=GmailRemoveLabelBlock.Output,
|
||||||
@@ -961,7 +961,7 @@ class GmailGetThreadBlock(GmailBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="21a79166-9df7-4b5f-9f36-96f639d86112",
|
id="21a79166-9df7-4b5f-9f36-96f639d86112",
|
||||||
description="Get a full Gmail thread by ID",
|
description="A block that retrieves an entire Gmail thread (email conversation) by ID, returning all messages with decoded bodies for reading complete conversations.",
|
||||||
categories={BlockCategory.COMMUNICATION},
|
categories={BlockCategory.COMMUNICATION},
|
||||||
input_schema=GmailGetThreadBlock.Input,
|
input_schema=GmailGetThreadBlock.Input,
|
||||||
output_schema=GmailGetThreadBlock.Output,
|
output_schema=GmailGetThreadBlock.Output,
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ class GoogleSheetsReadBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="5724e902-3635-47e9-a108-aaa0263a4988",
|
id="5724e902-3635-47e9-a108-aaa0263a4988",
|
||||||
description="This block reads data from a Google Sheets spreadsheet.",
|
description="A block that reads data from a Google Sheets spreadsheet using A1 notation range selection.",
|
||||||
categories={BlockCategory.DATA},
|
categories={BlockCategory.DATA},
|
||||||
input_schema=GoogleSheetsReadBlock.Input,
|
input_schema=GoogleSheetsReadBlock.Input,
|
||||||
output_schema=GoogleSheetsReadBlock.Output,
|
output_schema=GoogleSheetsReadBlock.Output,
|
||||||
@@ -409,7 +409,7 @@ class GoogleSheetsWriteBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="d9291e87-301d-47a8-91fe-907fb55460e5",
|
id="d9291e87-301d-47a8-91fe-907fb55460e5",
|
||||||
description="This block writes data to a Google Sheets spreadsheet.",
|
description="A block that writes data to a Google Sheets spreadsheet at a specified A1 notation range.",
|
||||||
categories={BlockCategory.DATA},
|
categories={BlockCategory.DATA},
|
||||||
input_schema=GoogleSheetsWriteBlock.Input,
|
input_schema=GoogleSheetsWriteBlock.Input,
|
||||||
output_schema=GoogleSheetsWriteBlock.Output,
|
output_schema=GoogleSheetsWriteBlock.Output,
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class HITLReviewHelper:
|
|||||||
Exception: If review creation or status update fails
|
Exception: If review creation or status update fails
|
||||||
"""
|
"""
|
||||||
# Skip review if safe mode is disabled - return auto-approved result
|
# Skip review if safe mode is disabled - return auto-approved result
|
||||||
if not execution_context.safe_mode:
|
if not execution_context.human_in_the_loop_safe_mode:
|
||||||
logger.info(
|
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} - safe mode disabled"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class HumanInTheLoopBlock(Block):
|
|||||||
execution_context: ExecutionContext,
|
execution_context: ExecutionContext,
|
||||||
**_kwargs,
|
**_kwargs,
|
||||||
) -> BlockOutput:
|
) -> BlockOutput:
|
||||||
if not execution_context.safe_mode:
|
if not execution_context.human_in_the_loop_safe_mode:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"HITL block skipping review for node {node_exec_id} - safe mode disabled"
|
f"HITL block skipping review for node {node_exec_id} - safe mode disabled"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class AgentInputBlock(Block):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
**{
|
**{
|
||||||
"id": "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
|
"id": "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
|
||||||
"description": "Base block for user inputs.",
|
"description": "A block that accepts and processes user input values within a workflow, supporting various input types and validation.",
|
||||||
"input_schema": AgentInputBlock.Input,
|
"input_schema": AgentInputBlock.Input,
|
||||||
"output_schema": AgentInputBlock.Output,
|
"output_schema": AgentInputBlock.Output,
|
||||||
"test_input": [
|
"test_input": [
|
||||||
@@ -168,7 +168,7 @@ class AgentOutputBlock(Block):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="363ae599-353e-4804-937e-b2ee3cef3da4",
|
id="363ae599-353e-4804-937e-b2ee3cef3da4",
|
||||||
description="Stores the output of the graph for users to see.",
|
description="A block that records and formats workflow results for display to users, with optional Jinja2 template formatting support.",
|
||||||
input_schema=AgentOutputBlock.Input,
|
input_schema=AgentOutputBlock.Input,
|
||||||
output_schema=AgentOutputBlock.Output,
|
output_schema=AgentOutputBlock.Output,
|
||||||
test_input=[
|
test_input=[
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ class ModelMetadata(NamedTuple):
|
|||||||
provider: str
|
provider: str
|
||||||
context_window: int
|
context_window: int
|
||||||
max_output_tokens: int | None
|
max_output_tokens: int | None
|
||||||
|
display_name: str
|
||||||
|
provider_name: str
|
||||||
|
creator_name: str
|
||||||
|
price_tier: Literal[1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
class LlmModelMeta(EnumMeta):
|
class LlmModelMeta(EnumMeta):
|
||||||
@@ -171,6 +175,26 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
|||||||
V0_1_5_LG = "v0-1.5-lg"
|
V0_1_5_LG = "v0-1.5-lg"
|
||||||
V0_1_0_MD = "v0-1.0-md"
|
V0_1_0_MD = "v0-1.0-md"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __get_pydantic_json_schema__(cls, schema, handler):
|
||||||
|
json_schema = handler(schema)
|
||||||
|
llm_model_metadata = {}
|
||||||
|
for model in cls:
|
||||||
|
model_name = model.value
|
||||||
|
metadata = model.metadata
|
||||||
|
llm_model_metadata[model_name] = {
|
||||||
|
"creator": metadata.creator_name,
|
||||||
|
"creator_name": metadata.creator_name,
|
||||||
|
"title": metadata.display_name,
|
||||||
|
"provider": metadata.provider,
|
||||||
|
"provider_name": metadata.provider_name,
|
||||||
|
"name": model_name,
|
||||||
|
"price_tier": metadata.price_tier,
|
||||||
|
}
|
||||||
|
json_schema["llm_model"] = True
|
||||||
|
json_schema["llm_model_metadata"] = llm_model_metadata
|
||||||
|
return json_schema
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def metadata(self) -> ModelMetadata:
|
def metadata(self) -> ModelMetadata:
|
||||||
return MODEL_METADATA[self]
|
return MODEL_METADATA[self]
|
||||||
@@ -190,119 +214,291 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta):
|
|||||||
|
|
||||||
MODEL_METADATA = {
|
MODEL_METADATA = {
|
||||||
# https://platform.openai.com/docs/models
|
# https://platform.openai.com/docs/models
|
||||||
LlmModel.O3: ModelMetadata("openai", 200000, 100000),
|
LlmModel.O3: ModelMetadata("openai", 200000, 100000, "O3", "OpenAI", "OpenAI", 2),
|
||||||
LlmModel.O3_MINI: ModelMetadata("openai", 200000, 100000), # o3-mini-2025-01-31
|
LlmModel.O3_MINI: ModelMetadata(
|
||||||
LlmModel.O1: ModelMetadata("openai", 200000, 100000), # o1-2024-12-17
|
"openai", 200000, 100000, "O3 Mini", "OpenAI", "OpenAI", 1
|
||||||
LlmModel.O1_MINI: ModelMetadata("openai", 128000, 65536), # o1-mini-2024-09-12
|
), # o3-mini-2025-01-31
|
||||||
|
LlmModel.O1: ModelMetadata(
|
||||||
|
"openai", 200000, 100000, "O1", "OpenAI", "OpenAI", 3
|
||||||
|
), # o1-2024-12-17
|
||||||
|
LlmModel.O1_MINI: ModelMetadata(
|
||||||
|
"openai", 128000, 65536, "O1 Mini", "OpenAI", "OpenAI", 2
|
||||||
|
), # o1-mini-2024-09-12
|
||||||
# GPT-5 models
|
# GPT-5 models
|
||||||
LlmModel.GPT5_2: ModelMetadata("openai", 400000, 128000),
|
LlmModel.GPT5_2: ModelMetadata(
|
||||||
LlmModel.GPT5_1: ModelMetadata("openai", 400000, 128000),
|
"openai", 400000, 128000, "GPT-5.2", "OpenAI", "OpenAI", 3
|
||||||
LlmModel.GPT5: ModelMetadata("openai", 400000, 128000),
|
),
|
||||||
LlmModel.GPT5_MINI: ModelMetadata("openai", 400000, 128000),
|
LlmModel.GPT5_1: ModelMetadata(
|
||||||
LlmModel.GPT5_NANO: ModelMetadata("openai", 400000, 128000),
|
"openai", 400000, 128000, "GPT-5.1", "OpenAI", "OpenAI", 2
|
||||||
LlmModel.GPT5_CHAT: ModelMetadata("openai", 400000, 16384),
|
),
|
||||||
LlmModel.GPT41: ModelMetadata("openai", 1047576, 32768),
|
LlmModel.GPT5: ModelMetadata(
|
||||||
LlmModel.GPT41_MINI: ModelMetadata("openai", 1047576, 32768),
|
"openai", 400000, 128000, "GPT-5", "OpenAI", "OpenAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.GPT5_MINI: ModelMetadata(
|
||||||
|
"openai", 400000, 128000, "GPT-5 Mini", "OpenAI", "OpenAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.GPT5_NANO: ModelMetadata(
|
||||||
|
"openai", 400000, 128000, "GPT-5 Nano", "OpenAI", "OpenAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.GPT5_CHAT: ModelMetadata(
|
||||||
|
"openai", 400000, 16384, "GPT-5 Chat Latest", "OpenAI", "OpenAI", 2
|
||||||
|
),
|
||||||
|
LlmModel.GPT41: ModelMetadata(
|
||||||
|
"openai", 1047576, 32768, "GPT-4.1", "OpenAI", "OpenAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.GPT41_MINI: ModelMetadata(
|
||||||
|
"openai", 1047576, 32768, "GPT-4.1 Mini", "OpenAI", "OpenAI", 1
|
||||||
|
),
|
||||||
LlmModel.GPT4O_MINI: ModelMetadata(
|
LlmModel.GPT4O_MINI: ModelMetadata(
|
||||||
"openai", 128000, 16384
|
"openai", 128000, 16384, "GPT-4o Mini", "OpenAI", "OpenAI", 1
|
||||||
), # gpt-4o-mini-2024-07-18
|
), # gpt-4o-mini-2024-07-18
|
||||||
LlmModel.GPT4O: ModelMetadata("openai", 128000, 16384), # gpt-4o-2024-08-06
|
LlmModel.GPT4O: ModelMetadata(
|
||||||
|
"openai", 128000, 16384, "GPT-4o", "OpenAI", "OpenAI", 2
|
||||||
|
), # gpt-4o-2024-08-06
|
||||||
LlmModel.GPT4_TURBO: ModelMetadata(
|
LlmModel.GPT4_TURBO: ModelMetadata(
|
||||||
"openai", 128000, 4096
|
"openai", 128000, 4096, "GPT-4 Turbo", "OpenAI", "OpenAI", 3
|
||||||
), # gpt-4-turbo-2024-04-09
|
), # gpt-4-turbo-2024-04-09
|
||||||
LlmModel.GPT3_5_TURBO: ModelMetadata("openai", 16385, 4096), # gpt-3.5-turbo-0125
|
LlmModel.GPT3_5_TURBO: ModelMetadata(
|
||||||
|
"openai", 16385, 4096, "GPT-3.5 Turbo", "OpenAI", "OpenAI", 1
|
||||||
|
), # gpt-3.5-turbo-0125
|
||||||
# https://docs.anthropic.com/en/docs/about-claude/models
|
# https://docs.anthropic.com/en/docs/about-claude/models
|
||||||
LlmModel.CLAUDE_4_1_OPUS: ModelMetadata(
|
LlmModel.CLAUDE_4_1_OPUS: ModelMetadata(
|
||||||
"anthropic", 200000, 32000
|
"anthropic", 200000, 32000, "Claude Opus 4.1", "Anthropic", "Anthropic", 3
|
||||||
), # claude-opus-4-1-20250805
|
), # claude-opus-4-1-20250805
|
||||||
LlmModel.CLAUDE_4_OPUS: ModelMetadata(
|
LlmModel.CLAUDE_4_OPUS: ModelMetadata(
|
||||||
"anthropic", 200000, 32000
|
"anthropic", 200000, 32000, "Claude Opus 4", "Anthropic", "Anthropic", 3
|
||||||
), # claude-4-opus-20250514
|
), # claude-4-opus-20250514
|
||||||
LlmModel.CLAUDE_4_SONNET: ModelMetadata(
|
LlmModel.CLAUDE_4_SONNET: ModelMetadata(
|
||||||
"anthropic", 200000, 64000
|
"anthropic", 200000, 64000, "Claude Sonnet 4", "Anthropic", "Anthropic", 2
|
||||||
), # claude-4-sonnet-20250514
|
), # claude-4-sonnet-20250514
|
||||||
LlmModel.CLAUDE_4_5_OPUS: ModelMetadata(
|
LlmModel.CLAUDE_4_5_OPUS: ModelMetadata(
|
||||||
"anthropic", 200000, 64000
|
"anthropic", 200000, 64000, "Claude Opus 4.5", "Anthropic", "Anthropic", 3
|
||||||
), # claude-opus-4-5-20251101
|
), # claude-opus-4-5-20251101
|
||||||
LlmModel.CLAUDE_4_5_SONNET: ModelMetadata(
|
LlmModel.CLAUDE_4_5_SONNET: ModelMetadata(
|
||||||
"anthropic", 200000, 64000
|
"anthropic", 200000, 64000, "Claude Sonnet 4.5", "Anthropic", "Anthropic", 3
|
||||||
), # claude-sonnet-4-5-20250929
|
), # claude-sonnet-4-5-20250929
|
||||||
LlmModel.CLAUDE_4_5_HAIKU: ModelMetadata(
|
LlmModel.CLAUDE_4_5_HAIKU: ModelMetadata(
|
||||||
"anthropic", 200000, 64000
|
"anthropic", 200000, 64000, "Claude Haiku 4.5", "Anthropic", "Anthropic", 2
|
||||||
), # claude-haiku-4-5-20251001
|
), # claude-haiku-4-5-20251001
|
||||||
LlmModel.CLAUDE_3_7_SONNET: ModelMetadata(
|
LlmModel.CLAUDE_3_7_SONNET: ModelMetadata(
|
||||||
"anthropic", 200000, 64000
|
"anthropic", 200000, 64000, "Claude 3.7 Sonnet", "Anthropic", "Anthropic", 2
|
||||||
), # claude-3-7-sonnet-20250219
|
), # claude-3-7-sonnet-20250219
|
||||||
LlmModel.CLAUDE_3_HAIKU: ModelMetadata(
|
LlmModel.CLAUDE_3_HAIKU: ModelMetadata(
|
||||||
"anthropic", 200000, 4096
|
"anthropic", 200000, 4096, "Claude 3 Haiku", "Anthropic", "Anthropic", 1
|
||||||
), # claude-3-haiku-20240307
|
), # claude-3-haiku-20240307
|
||||||
# https://docs.aimlapi.com/api-overview/model-database/text-models
|
# https://docs.aimlapi.com/api-overview/model-database/text-models
|
||||||
LlmModel.AIML_API_QWEN2_5_72B: ModelMetadata("aiml_api", 32000, 8000),
|
LlmModel.AIML_API_QWEN2_5_72B: ModelMetadata(
|
||||||
LlmModel.AIML_API_LLAMA3_1_70B: ModelMetadata("aiml_api", 128000, 40000),
|
"aiml_api", 32000, 8000, "Qwen 2.5 72B Instruct Turbo", "AI/ML", "Qwen", 1
|
||||||
LlmModel.AIML_API_LLAMA3_3_70B: ModelMetadata("aiml_api", 128000, None),
|
),
|
||||||
LlmModel.AIML_API_META_LLAMA_3_1_70B: ModelMetadata("aiml_api", 131000, 2000),
|
LlmModel.AIML_API_LLAMA3_1_70B: ModelMetadata(
|
||||||
LlmModel.AIML_API_LLAMA_3_2_3B: ModelMetadata("aiml_api", 128000, None),
|
"aiml_api",
|
||||||
# https://console.groq.com/docs/models
|
128000,
|
||||||
LlmModel.LLAMA3_3_70B: ModelMetadata("groq", 128000, 32768),
|
40000,
|
||||||
LlmModel.LLAMA3_1_8B: ModelMetadata("groq", 128000, 8192),
|
"Llama 3.1 Nemotron 70B Instruct",
|
||||||
# https://ollama.com/library
|
"AI/ML",
|
||||||
LlmModel.OLLAMA_LLAMA3_3: ModelMetadata("ollama", 8192, None),
|
"Nvidia",
|
||||||
LlmModel.OLLAMA_LLAMA3_2: ModelMetadata("ollama", 8192, None),
|
1,
|
||||||
LlmModel.OLLAMA_LLAMA3_8B: ModelMetadata("ollama", 8192, None),
|
),
|
||||||
LlmModel.OLLAMA_LLAMA3_405B: ModelMetadata("ollama", 8192, None),
|
LlmModel.AIML_API_LLAMA3_3_70B: ModelMetadata(
|
||||||
LlmModel.OLLAMA_DOLPHIN: ModelMetadata("ollama", 32768, None),
|
"aiml_api", 128000, None, "Llama 3.3 70B Instruct Turbo", "AI/ML", "Meta", 1
|
||||||
# https://openrouter.ai/models
|
),
|
||||||
LlmModel.GEMINI_2_5_PRO: ModelMetadata("open_router", 1050000, 8192),
|
LlmModel.AIML_API_META_LLAMA_3_1_70B: ModelMetadata(
|
||||||
LlmModel.GEMINI_3_PRO_PREVIEW: ModelMetadata("open_router", 1048576, 65535),
|
"aiml_api", 131000, 2000, "Llama 3.1 70B Instruct Turbo", "AI/ML", "Meta", 1
|
||||||
LlmModel.GEMINI_2_5_FLASH: ModelMetadata("open_router", 1048576, 65535),
|
),
|
||||||
LlmModel.GEMINI_2_0_FLASH: ModelMetadata("open_router", 1048576, 8192),
|
LlmModel.AIML_API_LLAMA_3_2_3B: ModelMetadata(
|
||||||
LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: ModelMetadata(
|
"aiml_api", 128000, None, "Llama 3.2 3B Instruct Turbo", "AI/ML", "Meta", 1
|
||||||
"open_router", 1048576, 65535
|
),
|
||||||
|
# https://console.groq.com/docs/models
|
||||||
|
LlmModel.LLAMA3_3_70B: ModelMetadata(
|
||||||
|
"groq", 128000, 32768, "Llama 3.3 70B Versatile", "Groq", "Meta", 1
|
||||||
|
),
|
||||||
|
LlmModel.LLAMA3_1_8B: ModelMetadata(
|
||||||
|
"groq", 128000, 8192, "Llama 3.1 8B Instant", "Groq", "Meta", 1
|
||||||
|
),
|
||||||
|
# https://ollama.com/library
|
||||||
|
LlmModel.OLLAMA_LLAMA3_3: ModelMetadata(
|
||||||
|
"ollama", 8192, None, "Llama 3.3", "Ollama", "Meta", 1
|
||||||
|
),
|
||||||
|
LlmModel.OLLAMA_LLAMA3_2: ModelMetadata(
|
||||||
|
"ollama", 8192, None, "Llama 3.2", "Ollama", "Meta", 1
|
||||||
|
),
|
||||||
|
LlmModel.OLLAMA_LLAMA3_8B: ModelMetadata(
|
||||||
|
"ollama", 8192, None, "Llama 3", "Ollama", "Meta", 1
|
||||||
|
),
|
||||||
|
LlmModel.OLLAMA_LLAMA3_405B: ModelMetadata(
|
||||||
|
"ollama", 8192, None, "Llama 3.1 405B", "Ollama", "Meta", 1
|
||||||
|
),
|
||||||
|
LlmModel.OLLAMA_DOLPHIN: ModelMetadata(
|
||||||
|
"ollama", 32768, None, "Dolphin Mistral Latest", "Ollama", "Mistral AI", 1
|
||||||
|
),
|
||||||
|
# https://openrouter.ai/models
|
||||||
|
LlmModel.GEMINI_2_5_PRO: ModelMetadata(
|
||||||
|
"open_router",
|
||||||
|
1050000,
|
||||||
|
8192,
|
||||||
|
"Gemini 2.5 Pro Preview 03.25",
|
||||||
|
"OpenRouter",
|
||||||
|
"Google",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
LlmModel.GEMINI_3_PRO_PREVIEW: ModelMetadata(
|
||||||
|
"open_router", 1048576, 65535, "Gemini 3 Pro Preview", "OpenRouter", "Google", 2
|
||||||
|
),
|
||||||
|
LlmModel.GEMINI_2_5_FLASH: ModelMetadata(
|
||||||
|
"open_router", 1048576, 65535, "Gemini 2.5 Flash", "OpenRouter", "Google", 1
|
||||||
|
),
|
||||||
|
LlmModel.GEMINI_2_0_FLASH: ModelMetadata(
|
||||||
|
"open_router", 1048576, 8192, "Gemini 2.0 Flash 001", "OpenRouter", "Google", 1
|
||||||
|
),
|
||||||
|
LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: ModelMetadata(
|
||||||
|
"open_router",
|
||||||
|
1048576,
|
||||||
|
65535,
|
||||||
|
"Gemini 2.5 Flash Lite Preview 06.17",
|
||||||
|
"OpenRouter",
|
||||||
|
"Google",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
LlmModel.GEMINI_2_0_FLASH_LITE: ModelMetadata(
|
||||||
|
"open_router",
|
||||||
|
1048576,
|
||||||
|
8192,
|
||||||
|
"Gemini 2.0 Flash Lite 001",
|
||||||
|
"OpenRouter",
|
||||||
|
"Google",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
LlmModel.MISTRAL_NEMO: ModelMetadata(
|
||||||
|
"open_router", 128000, 4096, "Mistral Nemo", "OpenRouter", "Mistral AI", 1
|
||||||
|
),
|
||||||
|
LlmModel.COHERE_COMMAND_R_08_2024: ModelMetadata(
|
||||||
|
"open_router", 128000, 4096, "Command R 08.2024", "OpenRouter", "Cohere", 1
|
||||||
|
),
|
||||||
|
LlmModel.COHERE_COMMAND_R_PLUS_08_2024: ModelMetadata(
|
||||||
|
"open_router", 128000, 4096, "Command R Plus 08.2024", "OpenRouter", "Cohere", 2
|
||||||
|
),
|
||||||
|
LlmModel.DEEPSEEK_CHAT: ModelMetadata(
|
||||||
|
"open_router", 64000, 2048, "DeepSeek Chat", "OpenRouter", "DeepSeek", 1
|
||||||
|
),
|
||||||
|
LlmModel.DEEPSEEK_R1_0528: ModelMetadata(
|
||||||
|
"open_router", 163840, 163840, "DeepSeek R1 0528", "OpenRouter", "DeepSeek", 1
|
||||||
|
),
|
||||||
|
LlmModel.PERPLEXITY_SONAR: ModelMetadata(
|
||||||
|
"open_router", 127000, 8000, "Sonar", "OpenRouter", "Perplexity", 1
|
||||||
|
),
|
||||||
|
LlmModel.PERPLEXITY_SONAR_PRO: ModelMetadata(
|
||||||
|
"open_router", 200000, 8000, "Sonar Pro", "OpenRouter", "Perplexity", 2
|
||||||
),
|
),
|
||||||
LlmModel.GEMINI_2_0_FLASH_LITE: ModelMetadata("open_router", 1048576, 8192),
|
|
||||||
LlmModel.MISTRAL_NEMO: ModelMetadata("open_router", 128000, 4096),
|
|
||||||
LlmModel.COHERE_COMMAND_R_08_2024: ModelMetadata("open_router", 128000, 4096),
|
|
||||||
LlmModel.COHERE_COMMAND_R_PLUS_08_2024: ModelMetadata("open_router", 128000, 4096),
|
|
||||||
LlmModel.DEEPSEEK_CHAT: ModelMetadata("open_router", 64000, 2048),
|
|
||||||
LlmModel.DEEPSEEK_R1_0528: ModelMetadata("open_router", 163840, 163840),
|
|
||||||
LlmModel.PERPLEXITY_SONAR: ModelMetadata("open_router", 127000, 8000),
|
|
||||||
LlmModel.PERPLEXITY_SONAR_PRO: ModelMetadata("open_router", 200000, 8000),
|
|
||||||
LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: ModelMetadata(
|
LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: ModelMetadata(
|
||||||
"open_router",
|
"open_router",
|
||||||
128000,
|
128000,
|
||||||
16000,
|
16000,
|
||||||
|
"Sonar Deep Research",
|
||||||
|
"OpenRouter",
|
||||||
|
"Perplexity",
|
||||||
|
3,
|
||||||
),
|
),
|
||||||
LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_405B: ModelMetadata(
|
LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_405B: ModelMetadata(
|
||||||
"open_router", 131000, 4096
|
"open_router",
|
||||||
|
131000,
|
||||||
|
4096,
|
||||||
|
"Hermes 3 Llama 3.1 405B",
|
||||||
|
"OpenRouter",
|
||||||
|
"Nous Research",
|
||||||
|
1,
|
||||||
),
|
),
|
||||||
LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_70B: ModelMetadata(
|
LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_70B: ModelMetadata(
|
||||||
"open_router", 12288, 12288
|
"open_router",
|
||||||
|
12288,
|
||||||
|
12288,
|
||||||
|
"Hermes 3 Llama 3.1 70B",
|
||||||
|
"OpenRouter",
|
||||||
|
"Nous Research",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
LlmModel.OPENAI_GPT_OSS_120B: ModelMetadata(
|
||||||
|
"open_router", 131072, 131072, "GPT-OSS 120B", "OpenRouter", "OpenAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.OPENAI_GPT_OSS_20B: ModelMetadata(
|
||||||
|
"open_router", 131072, 32768, "GPT-OSS 20B", "OpenRouter", "OpenAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.AMAZON_NOVA_LITE_V1: ModelMetadata(
|
||||||
|
"open_router", 300000, 5120, "Nova Lite V1", "OpenRouter", "Amazon", 1
|
||||||
|
),
|
||||||
|
LlmModel.AMAZON_NOVA_MICRO_V1: ModelMetadata(
|
||||||
|
"open_router", 128000, 5120, "Nova Micro V1", "OpenRouter", "Amazon", 1
|
||||||
|
),
|
||||||
|
LlmModel.AMAZON_NOVA_PRO_V1: ModelMetadata(
|
||||||
|
"open_router", 300000, 5120, "Nova Pro V1", "OpenRouter", "Amazon", 1
|
||||||
|
),
|
||||||
|
LlmModel.MICROSOFT_WIZARDLM_2_8X22B: ModelMetadata(
|
||||||
|
"open_router", 65536, 4096, "WizardLM 2 8x22B", "OpenRouter", "Microsoft", 1
|
||||||
|
),
|
||||||
|
LlmModel.GRYPHE_MYTHOMAX_L2_13B: ModelMetadata(
|
||||||
|
"open_router", 4096, 4096, "MythoMax L2 13B", "OpenRouter", "Gryphe", 1
|
||||||
|
),
|
||||||
|
LlmModel.META_LLAMA_4_SCOUT: ModelMetadata(
|
||||||
|
"open_router", 131072, 131072, "Llama 4 Scout", "OpenRouter", "Meta", 1
|
||||||
|
),
|
||||||
|
LlmModel.META_LLAMA_4_MAVERICK: ModelMetadata(
|
||||||
|
"open_router", 1048576, 1000000, "Llama 4 Maverick", "OpenRouter", "Meta", 1
|
||||||
|
),
|
||||||
|
LlmModel.GROK_4: ModelMetadata(
|
||||||
|
"open_router", 256000, 256000, "Grok 4", "OpenRouter", "xAI", 3
|
||||||
|
),
|
||||||
|
LlmModel.GROK_4_FAST: ModelMetadata(
|
||||||
|
"open_router", 2000000, 30000, "Grok 4 Fast", "OpenRouter", "xAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.GROK_4_1_FAST: ModelMetadata(
|
||||||
|
"open_router", 2000000, 30000, "Grok 4.1 Fast", "OpenRouter", "xAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.GROK_CODE_FAST_1: ModelMetadata(
|
||||||
|
"open_router", 256000, 10000, "Grok Code Fast 1", "OpenRouter", "xAI", 1
|
||||||
|
),
|
||||||
|
LlmModel.KIMI_K2: ModelMetadata(
|
||||||
|
"open_router", 131000, 131000, "Kimi K2", "OpenRouter", "Moonshot AI", 1
|
||||||
|
),
|
||||||
|
LlmModel.QWEN3_235B_A22B_THINKING: ModelMetadata(
|
||||||
|
"open_router",
|
||||||
|
262144,
|
||||||
|
262144,
|
||||||
|
"Qwen 3 235B A22B Thinking 2507",
|
||||||
|
"OpenRouter",
|
||||||
|
"Qwen",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
LlmModel.QWEN3_CODER: ModelMetadata(
|
||||||
|
"open_router", 262144, 262144, "Qwen 3 Coder", "OpenRouter", "Qwen", 3
|
||||||
),
|
),
|
||||||
LlmModel.OPENAI_GPT_OSS_120B: ModelMetadata("open_router", 131072, 131072),
|
|
||||||
LlmModel.OPENAI_GPT_OSS_20B: ModelMetadata("open_router", 131072, 32768),
|
|
||||||
LlmModel.AMAZON_NOVA_LITE_V1: ModelMetadata("open_router", 300000, 5120),
|
|
||||||
LlmModel.AMAZON_NOVA_MICRO_V1: ModelMetadata("open_router", 128000, 5120),
|
|
||||||
LlmModel.AMAZON_NOVA_PRO_V1: ModelMetadata("open_router", 300000, 5120),
|
|
||||||
LlmModel.MICROSOFT_WIZARDLM_2_8X22B: ModelMetadata("open_router", 65536, 4096),
|
|
||||||
LlmModel.GRYPHE_MYTHOMAX_L2_13B: ModelMetadata("open_router", 4096, 4096),
|
|
||||||
LlmModel.META_LLAMA_4_SCOUT: ModelMetadata("open_router", 131072, 131072),
|
|
||||||
LlmModel.META_LLAMA_4_MAVERICK: ModelMetadata("open_router", 1048576, 1000000),
|
|
||||||
LlmModel.GROK_4: ModelMetadata("open_router", 256000, 256000),
|
|
||||||
LlmModel.GROK_4_FAST: ModelMetadata("open_router", 2000000, 30000),
|
|
||||||
LlmModel.GROK_4_1_FAST: ModelMetadata("open_router", 2000000, 30000),
|
|
||||||
LlmModel.GROK_CODE_FAST_1: ModelMetadata("open_router", 256000, 10000),
|
|
||||||
LlmModel.KIMI_K2: ModelMetadata("open_router", 131000, 131000),
|
|
||||||
LlmModel.QWEN3_235B_A22B_THINKING: ModelMetadata("open_router", 262144, 262144),
|
|
||||||
LlmModel.QWEN3_CODER: ModelMetadata("open_router", 262144, 262144),
|
|
||||||
# Llama API models
|
# Llama API models
|
||||||
LlmModel.LLAMA_API_LLAMA_4_SCOUT: ModelMetadata("llama_api", 128000, 4028),
|
LlmModel.LLAMA_API_LLAMA_4_SCOUT: ModelMetadata(
|
||||||
LlmModel.LLAMA_API_LLAMA4_MAVERICK: ModelMetadata("llama_api", 128000, 4028),
|
"llama_api",
|
||||||
LlmModel.LLAMA_API_LLAMA3_3_8B: ModelMetadata("llama_api", 128000, 4028),
|
128000,
|
||||||
LlmModel.LLAMA_API_LLAMA3_3_70B: ModelMetadata("llama_api", 128000, 4028),
|
4028,
|
||||||
|
"Llama 4 Scout 17B 16E Instruct FP8",
|
||||||
|
"Llama API",
|
||||||
|
"Meta",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
LlmModel.LLAMA_API_LLAMA4_MAVERICK: ModelMetadata(
|
||||||
|
"llama_api",
|
||||||
|
128000,
|
||||||
|
4028,
|
||||||
|
"Llama 4 Maverick 17B 128E Instruct FP8",
|
||||||
|
"Llama API",
|
||||||
|
"Meta",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
LlmModel.LLAMA_API_LLAMA3_3_8B: ModelMetadata(
|
||||||
|
"llama_api", 128000, 4028, "Llama 3.3 8B Instruct", "Llama API", "Meta", 1
|
||||||
|
),
|
||||||
|
LlmModel.LLAMA_API_LLAMA3_3_70B: ModelMetadata(
|
||||||
|
"llama_api", 128000, 4028, "Llama 3.3 70B Instruct", "Llama API", "Meta", 1
|
||||||
|
),
|
||||||
# v0 by Vercel models
|
# v0 by Vercel models
|
||||||
LlmModel.V0_1_5_MD: ModelMetadata("v0", 128000, 64000),
|
LlmModel.V0_1_5_MD: ModelMetadata("v0", 128000, 64000, "v0 1.5 MD", "V0", "V0", 1),
|
||||||
LlmModel.V0_1_5_LG: ModelMetadata("v0", 512000, 64000),
|
LlmModel.V0_1_5_LG: ModelMetadata("v0", 512000, 64000, "v0 1.5 LG", "V0", "V0", 1),
|
||||||
LlmModel.V0_1_0_MD: ModelMetadata("v0", 128000, 64000),
|
LlmModel.V0_1_0_MD: ModelMetadata("v0", 128000, 64000, "v0 1.0 MD", "V0", "V0", 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_LLM_MODEL = LlmModel.GPT5_2
|
DEFAULT_LLM_MODEL = LlmModel.GPT5_2
|
||||||
@@ -854,7 +1050,7 @@ class AIStructuredResponseGeneratorBlock(AIBlockBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="ed55ac19-356e-4243-a6cb-bc599e9b716f",
|
id="ed55ac19-356e-4243-a6cb-bc599e9b716f",
|
||||||
description="Call a Large Language Model (LLM) to generate formatted object based on the given prompt.",
|
description="A block that generates structured JSON responses using a Large Language Model (LLM), with schema validation and format enforcement.",
|
||||||
categories={BlockCategory.AI},
|
categories={BlockCategory.AI},
|
||||||
input_schema=AIStructuredResponseGeneratorBlock.Input,
|
input_schema=AIStructuredResponseGeneratorBlock.Input,
|
||||||
output_schema=AIStructuredResponseGeneratorBlock.Output,
|
output_schema=AIStructuredResponseGeneratorBlock.Output,
|
||||||
@@ -1265,7 +1461,7 @@ class AITextGeneratorBlock(AIBlockBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="1f292d4a-41a4-4977-9684-7c8d560b9f91",
|
id="1f292d4a-41a4-4977-9684-7c8d560b9f91",
|
||||||
description="Call a Large Language Model (LLM) to generate a string based on the given prompt.",
|
description="A block that produces text responses using a Large Language Model (LLM) based on customizable prompts and system instructions.",
|
||||||
categories={BlockCategory.AI},
|
categories={BlockCategory.AI},
|
||||||
input_schema=AITextGeneratorBlock.Input,
|
input_schema=AITextGeneratorBlock.Input,
|
||||||
output_schema=AITextGeneratorBlock.Output,
|
output_schema=AITextGeneratorBlock.Output,
|
||||||
@@ -1361,7 +1557,7 @@ class AITextSummarizerBlock(AIBlockBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="a0a69be1-4528-491c-a85a-a4ab6873e3f0",
|
id="a0a69be1-4528-491c-a85a-a4ab6873e3f0",
|
||||||
description="Utilize a Large Language Model (LLM) to summarize a long text.",
|
description="A block that summarizes long texts using a Large Language Model (LLM), with configurable focus topics and summary styles.",
|
||||||
categories={BlockCategory.AI, BlockCategory.TEXT},
|
categories={BlockCategory.AI, BlockCategory.TEXT},
|
||||||
input_schema=AITextSummarizerBlock.Input,
|
input_schema=AITextSummarizerBlock.Input,
|
||||||
output_schema=AITextSummarizerBlock.Output,
|
output_schema=AITextSummarizerBlock.Output,
|
||||||
@@ -1562,7 +1758,7 @@ class AIConversationBlock(AIBlockBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="32a87eab-381e-4dd4-bdb8-4c47151be35a",
|
id="32a87eab-381e-4dd4-bdb8-4c47151be35a",
|
||||||
description="Advanced LLM call that takes a list of messages and sends them to the language model.",
|
description="A block that facilitates multi-turn conversations with a Large Language Model (LLM), maintaining context across message exchanges.",
|
||||||
categories={BlockCategory.AI},
|
categories={BlockCategory.AI},
|
||||||
input_schema=AIConversationBlock.Input,
|
input_schema=AIConversationBlock.Input,
|
||||||
output_schema=AIConversationBlock.Output,
|
output_schema=AIConversationBlock.Output,
|
||||||
@@ -1682,7 +1878,7 @@ class AIListGeneratorBlock(AIBlockBase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id="9c0b0450-d199-458b-a731-072189dd6593",
|
id="9c0b0450-d199-458b-a731-072189dd6593",
|
||||||
description="Generate a list of values based on the given prompt using a Large Language Model (LLM).",
|
description="A block that creates lists of items based on prompts using a Large Language Model (LLM), with optional source data for context.",
|
||||||
categories={BlockCategory.AI, BlockCategory.TEXT},
|
categories={BlockCategory.AI, BlockCategory.TEXT},
|
||||||
input_schema=AIListGeneratorBlock.Input,
|
input_schema=AIListGeneratorBlock.Input,
|
||||||
output_schema=AIListGeneratorBlock.Output,
|
output_schema=AIListGeneratorBlock.Output,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class PublishToMediumBlock(Block):
|
|||||||
class Input(BlockSchemaInput):
|
class Input(BlockSchemaInput):
|
||||||
author_id: BlockSecret = SecretField(
|
author_id: BlockSecret = SecretField(
|
||||||
key="medium_author_id",
|
key="medium_author_id",
|
||||||
description="""The Medium AuthorID of the user. You can get this by calling the /me endpoint of the Medium API.\n\ncurl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" https://api.medium.com/v1/me" the response will contain the authorId field.""",
|
description="""The Medium AuthorID of the user. You can get this by calling the /me endpoint of the Medium API.\n\ncurl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" https://api.medium.com/v1/me\n\nThe response will contain the authorId field.""",
|
||||||
placeholder="Enter the author's Medium AuthorID",
|
placeholder="Enter the author's Medium AuthorID",
|
||||||
)
|
)
|
||||||
title: str = SchemaField(
|
title: str = SchemaField(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class CreateTalkingAvatarVideoBlock(Block):
|
|||||||
description="The voice provider to use", default="microsoft"
|
description="The voice provider to use", default="microsoft"
|
||||||
)
|
)
|
||||||
voice_id: str = SchemaField(
|
voice_id: str = SchemaField(
|
||||||
description="The voice ID to use, get list of voices [here](https://docs.agpt.co/server/d_id)",
|
description="The voice ID to use, see [available voice IDs](https://agpt.co/docs/platform/using-ai-services/d_id)",
|
||||||
default="en-US-JennyNeural",
|
default="en-US-JennyNeural",
|
||||||
)
|
)
|
||||||
presenter_id: str = SchemaField(
|
presenter_id: str = SchemaField(
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ async def test_smart_decision_maker_tracks_llm_stats():
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@ async def test_smart_decision_maker_parameter_validation():
|
|||||||
|
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
@@ -409,7 +409,7 @@ async def test_smart_decision_maker_parameter_validation():
|
|||||||
|
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
@@ -471,7 +471,7 @@ async def test_smart_decision_maker_parameter_validation():
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
@@ -535,7 +535,7 @@ async def test_smart_decision_maker_parameter_validation():
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
@@ -658,7 +658,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
@@ -730,7 +730,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
@@ -786,7 +786,7 @@ async def test_smart_decision_maker_raw_response_conversion():
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
@@ -905,7 +905,7 @@ async def test_smart_decision_maker_agent_mode():
|
|||||||
# Create a mock execution context
|
# Create a mock execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(
|
mock_execution_context = ExecutionContext(
|
||||||
safe_mode=False,
|
human_in_the_loop_safe_mode=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a mock execution processor for agent mode tests
|
# Create a mock execution processor for agent mode tests
|
||||||
@@ -1027,7 +1027,7 @@ async def test_smart_decision_maker_traditional_mode_default():
|
|||||||
|
|
||||||
# Create execution context
|
# Create execution context
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
|
|
||||||
# Create a mock execution processor for tests
|
# Create a mock execution processor for tests
|
||||||
|
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ async def test_output_yielding_with_dynamic_fields():
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
from backend.data.execution import ExecutionContext
|
from backend.data.execution import ExecutionContext
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(human_in_the_loop_safe_mode=False)
|
||||||
mock_execution_processor = MagicMock()
|
mock_execution_processor = MagicMock()
|
||||||
|
|
||||||
async for output_name, output_value in block.run(
|
async for output_name, output_value in block.run(
|
||||||
@@ -609,7 +609,9 @@ async def test_validation_errors_dont_pollute_conversation():
|
|||||||
outputs = {}
|
outputs = {}
|
||||||
from backend.data.execution import ExecutionContext
|
from backend.data.execution import ExecutionContext
|
||||||
|
|
||||||
mock_execution_context = ExecutionContext(safe_mode=False)
|
mock_execution_context = ExecutionContext(
|
||||||
|
human_in_the_loop_safe_mode=False
|
||||||
|
)
|
||||||
|
|
||||||
# Create a proper mock execution processor for agent mode
|
# Create a proper mock execution processor for agent mode
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
|||||||
self.block_type = block_type
|
self.block_type = block_type
|
||||||
self.webhook_config = webhook_config
|
self.webhook_config = webhook_config
|
||||||
self.execution_stats: NodeExecutionStats = NodeExecutionStats()
|
self.execution_stats: NodeExecutionStats = NodeExecutionStats()
|
||||||
self.requires_human_review: bool = False
|
self.is_sensitive_action: bool = False
|
||||||
|
|
||||||
if self.webhook_config:
|
if self.webhook_config:
|
||||||
if isinstance(self.webhook_config, BlockWebhookConfig):
|
if isinstance(self.webhook_config, BlockWebhookConfig):
|
||||||
@@ -637,8 +637,9 @@ class Block(ABC, Generic[BlockSchemaInputType, BlockSchemaOutputType]):
|
|||||||
- should_pause: True if execution should be paused for review
|
- should_pause: True if execution should be paused for review
|
||||||
- input_data_to_use: The input data to use (may be modified by reviewer)
|
- input_data_to_use: The input data to use (may be modified by reviewer)
|
||||||
"""
|
"""
|
||||||
# Skip review if not required or safe mode is disabled
|
if not (
|
||||||
if not self.requires_human_review or not execution_context.safe_mode:
|
self.is_sensitive_action and execution_context.sensitive_action_safe_mode
|
||||||
|
):
|
||||||
return False, input_data
|
return False, input_data
|
||||||
|
|
||||||
from backend.blocks.helpers.review import HITLReviewHelper
|
from backend.blocks.helpers.review import HITLReviewHelper
|
||||||
|
|||||||
@@ -99,10 +99,15 @@ MODEL_COST: dict[LlmModel, int] = {
|
|||||||
LlmModel.OPENAI_GPT_OSS_20B: 1,
|
LlmModel.OPENAI_GPT_OSS_20B: 1,
|
||||||
LlmModel.GEMINI_2_5_PRO: 4,
|
LlmModel.GEMINI_2_5_PRO: 4,
|
||||||
LlmModel.GEMINI_3_PRO_PREVIEW: 5,
|
LlmModel.GEMINI_3_PRO_PREVIEW: 5,
|
||||||
|
LlmModel.GEMINI_2_5_FLASH: 1,
|
||||||
|
LlmModel.GEMINI_2_0_FLASH: 1,
|
||||||
|
LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: 1,
|
||||||
|
LlmModel.GEMINI_2_0_FLASH_LITE: 1,
|
||||||
LlmModel.MISTRAL_NEMO: 1,
|
LlmModel.MISTRAL_NEMO: 1,
|
||||||
LlmModel.COHERE_COMMAND_R_08_2024: 1,
|
LlmModel.COHERE_COMMAND_R_08_2024: 1,
|
||||||
LlmModel.COHERE_COMMAND_R_PLUS_08_2024: 3,
|
LlmModel.COHERE_COMMAND_R_PLUS_08_2024: 3,
|
||||||
LlmModel.DEEPSEEK_CHAT: 2,
|
LlmModel.DEEPSEEK_CHAT: 2,
|
||||||
|
LlmModel.DEEPSEEK_R1_0528: 1,
|
||||||
LlmModel.PERPLEXITY_SONAR: 1,
|
LlmModel.PERPLEXITY_SONAR: 1,
|
||||||
LlmModel.PERPLEXITY_SONAR_PRO: 5,
|
LlmModel.PERPLEXITY_SONAR_PRO: 5,
|
||||||
LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: 10,
|
LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: 10,
|
||||||
@@ -126,11 +131,6 @@ MODEL_COST: dict[LlmModel, int] = {
|
|||||||
LlmModel.KIMI_K2: 1,
|
LlmModel.KIMI_K2: 1,
|
||||||
LlmModel.QWEN3_235B_A22B_THINKING: 1,
|
LlmModel.QWEN3_235B_A22B_THINKING: 1,
|
||||||
LlmModel.QWEN3_CODER: 9,
|
LlmModel.QWEN3_CODER: 9,
|
||||||
LlmModel.GEMINI_2_5_FLASH: 1,
|
|
||||||
LlmModel.GEMINI_2_0_FLASH: 1,
|
|
||||||
LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: 1,
|
|
||||||
LlmModel.GEMINI_2_0_FLASH_LITE: 1,
|
|
||||||
LlmModel.DEEPSEEK_R1_0528: 1,
|
|
||||||
# v0 by Vercel models
|
# v0 by Vercel models
|
||||||
LlmModel.V0_1_5_MD: 1,
|
LlmModel.V0_1_5_MD: 1,
|
||||||
LlmModel.V0_1_5_LG: 2,
|
LlmModel.V0_1_5_LG: 2,
|
||||||
|
|||||||
@@ -38,20 +38,6 @@ POOL_TIMEOUT = os.getenv("DB_POOL_TIMEOUT")
|
|||||||
if POOL_TIMEOUT:
|
if POOL_TIMEOUT:
|
||||||
DATABASE_URL = add_param(DATABASE_URL, "pool_timeout", POOL_TIMEOUT)
|
DATABASE_URL = add_param(DATABASE_URL, "pool_timeout", POOL_TIMEOUT)
|
||||||
|
|
||||||
# Add public schema to search_path for pgvector type access
|
|
||||||
# The vector extension is in public schema, but search_path is determined by schema parameter
|
|
||||||
# Extract the schema from DATABASE_URL or default to 'public' (matching get_database_schema())
|
|
||||||
parsed_url = urlparse(DATABASE_URL)
|
|
||||||
url_params = dict(parse_qsl(parsed_url.query))
|
|
||||||
db_schema = url_params.get("schema", "public")
|
|
||||||
# Build search_path, avoiding duplicates if db_schema is already 'public'
|
|
||||||
search_path_schemas = list(
|
|
||||||
dict.fromkeys([db_schema, "public"])
|
|
||||||
) # Preserves order, removes duplicates
|
|
||||||
search_path = ",".join(search_path_schemas)
|
|
||||||
# This allows using ::vector without schema qualification
|
|
||||||
DATABASE_URL = add_param(DATABASE_URL, "options", f"-c search_path={search_path}")
|
|
||||||
|
|
||||||
HTTP_TIMEOUT = int(POOL_TIMEOUT) if POOL_TIMEOUT else None
|
HTTP_TIMEOUT = int(POOL_TIMEOUT) if POOL_TIMEOUT else None
|
||||||
|
|
||||||
prisma = Prisma(
|
prisma = Prisma(
|
||||||
@@ -127,38 +113,48 @@ async def _raw_with_schema(
|
|||||||
*args,
|
*args,
|
||||||
execute: bool = False,
|
execute: bool = False,
|
||||||
client: Prisma | None = None,
|
client: Prisma | None = None,
|
||||||
set_public_search_path: bool = False,
|
|
||||||
) -> list[dict] | int:
|
) -> list[dict] | int:
|
||||||
"""Internal: Execute raw SQL with proper schema handling.
|
"""Internal: Execute raw SQL with proper schema handling.
|
||||||
|
|
||||||
Use query_raw_with_schema() or execute_raw_with_schema() instead.
|
Use query_raw_with_schema() or execute_raw_with_schema() instead.
|
||||||
|
|
||||||
|
Supports placeholders:
|
||||||
|
- {schema_prefix}: Table/type prefix (e.g., "platform".)
|
||||||
|
- {schema}: Raw schema name for application tables (e.g., platform)
|
||||||
|
|
||||||
|
Note on pgvector types:
|
||||||
|
Use unqualified ::vector and <=> operator in queries. PostgreSQL resolves
|
||||||
|
these via search_path, which includes the schema where pgvector is installed
|
||||||
|
on all environments (local, CI, dev).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query_template: SQL query with {schema_prefix} placeholder
|
query_template: SQL query with {schema_prefix} and/or {schema} placeholders
|
||||||
*args: Query parameters
|
*args: Query parameters
|
||||||
execute: If False, executes SELECT query. If True, executes INSERT/UPDATE/DELETE.
|
execute: If False, executes SELECT query. If True, executes INSERT/UPDATE/DELETE.
|
||||||
client: Optional Prisma client for transactions (only used when execute=True).
|
client: Optional Prisma client for transactions (only used when execute=True).
|
||||||
set_public_search_path: If True, sets search_path to include public schema.
|
|
||||||
Needed for pgvector types and other public schema objects.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- list[dict] if execute=False (query results)
|
- list[dict] if execute=False (query results)
|
||||||
- int if execute=True (number of affected rows)
|
- int if execute=True (number of affected rows)
|
||||||
|
|
||||||
|
Example with vector type:
|
||||||
|
await execute_raw_with_schema(
|
||||||
|
'INSERT INTO {schema_prefix}"Embedding" (vec) VALUES ($1::vector)',
|
||||||
|
embedding_data
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
schema = get_database_schema()
|
schema = get_database_schema()
|
||||||
schema_prefix = f'"{schema}".' if schema != "public" else ""
|
schema_prefix = f'"{schema}".' if schema != "public" else ""
|
||||||
formatted_query = query_template.format(schema_prefix=schema_prefix)
|
|
||||||
|
formatted_query = query_template.format(
|
||||||
|
schema_prefix=schema_prefix,
|
||||||
|
schema=schema,
|
||||||
|
)
|
||||||
|
|
||||||
import prisma as prisma_module
|
import prisma as prisma_module
|
||||||
|
|
||||||
db_client = client if client else prisma_module.get_client()
|
db_client = client if client else prisma_module.get_client()
|
||||||
|
|
||||||
# Set search_path to include public schema if requested
|
|
||||||
# Prisma doesn't support the 'options' connection parameter, so we set it per-session
|
|
||||||
# This is idempotent and safe to call multiple times
|
|
||||||
if set_public_search_path:
|
|
||||||
await db_client.execute_raw(f"SET search_path = {schema}, public") # type: ignore
|
|
||||||
|
|
||||||
if execute:
|
if execute:
|
||||||
result = await db_client.execute_raw(formatted_query, *args) # type: ignore
|
result = await db_client.execute_raw(formatted_query, *args) # type: ignore
|
||||||
else:
|
else:
|
||||||
@@ -167,16 +163,12 @@ async def _raw_with_schema(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def query_raw_with_schema(
|
async def query_raw_with_schema(query_template: str, *args) -> list[dict]:
|
||||||
query_template: str, *args, set_public_search_path: bool = False
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Execute raw SQL SELECT query with proper schema handling.
|
"""Execute raw SQL SELECT query with proper schema handling.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query_template: SQL query with {schema_prefix} placeholder
|
query_template: SQL query with {schema_prefix} and/or {schema} placeholders
|
||||||
*args: Query parameters
|
*args: Query parameters
|
||||||
set_public_search_path: If True, sets search_path to include public schema.
|
|
||||||
Needed for pgvector types and other public schema objects.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of result rows as dictionaries
|
List of result rows as dictionaries
|
||||||
@@ -187,23 +179,20 @@ async def query_raw_with_schema(
|
|||||||
user_id
|
user_id
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
return await _raw_with_schema(query_template, *args, execute=False, set_public_search_path=set_public_search_path) # type: ignore
|
return await _raw_with_schema(query_template, *args, execute=False) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
async def execute_raw_with_schema(
|
async def execute_raw_with_schema(
|
||||||
query_template: str,
|
query_template: str,
|
||||||
*args,
|
*args,
|
||||||
client: Prisma | None = None,
|
client: Prisma | None = None,
|
||||||
set_public_search_path: bool = False,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Execute raw SQL command (INSERT/UPDATE/DELETE) with proper schema handling.
|
"""Execute raw SQL command (INSERT/UPDATE/DELETE) with proper schema handling.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query_template: SQL query with {schema_prefix} placeholder
|
query_template: SQL query with {schema_prefix} and/or {schema} placeholders
|
||||||
*args: Query parameters
|
*args: Query parameters
|
||||||
client: Optional Prisma client for transactions
|
client: Optional Prisma client for transactions
|
||||||
set_public_search_path: If True, sets search_path to include public schema.
|
|
||||||
Needed for pgvector types and other public schema objects.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of affected rows
|
Number of affected rows
|
||||||
@@ -215,7 +204,7 @@ async def execute_raw_with_schema(
|
|||||||
client=tx # Optional transaction client
|
client=tx # Optional transaction client
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
return await _raw_with_schema(query_template, *args, execute=True, client=client, set_public_search_path=set_public_search_path) # type: ignore
|
return await _raw_with_schema(query_template, *args, execute=True, client=client) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class BaseDbModel(BaseModel):
|
class BaseDbModel(BaseModel):
|
||||||
|
|||||||
@@ -103,8 +103,18 @@ class RedisEventBus(BaseRedisEventBus[M], ABC):
|
|||||||
return redis.get_redis()
|
return redis.get_redis()
|
||||||
|
|
||||||
def publish_event(self, event: M, channel_key: str):
|
def publish_event(self, event: M, channel_key: str):
|
||||||
message, full_channel_name = self._serialize_message(event, channel_key)
|
"""
|
||||||
self.connection.publish(full_channel_name, message)
|
Publish an event to Redis. Gracefully handles connection failures
|
||||||
|
by logging the error instead of raising exceptions.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
message, full_channel_name = self._serialize_message(event, channel_key)
|
||||||
|
self.connection.publish(full_channel_name, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Failed to publish event to Redis channel {channel_key}. "
|
||||||
|
"Event bus operation will continue without Redis connectivity."
|
||||||
|
)
|
||||||
|
|
||||||
def listen_events(self, channel_key: str) -> Generator[M, None, None]:
|
def listen_events(self, channel_key: str) -> Generator[M, None, None]:
|
||||||
pubsub, full_channel_name = self._get_pubsub_channel(
|
pubsub, full_channel_name = self._get_pubsub_channel(
|
||||||
@@ -128,9 +138,19 @@ class AsyncRedisEventBus(BaseRedisEventBus[M], ABC):
|
|||||||
return await redis.get_redis_async()
|
return await redis.get_redis_async()
|
||||||
|
|
||||||
async def publish_event(self, event: M, channel_key: str):
|
async def publish_event(self, event: M, channel_key: str):
|
||||||
message, full_channel_name = self._serialize_message(event, channel_key)
|
"""
|
||||||
connection = await self.connection
|
Publish an event to Redis. Gracefully handles connection failures
|
||||||
await connection.publish(full_channel_name, message)
|
by logging the error instead of raising exceptions.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
message, full_channel_name = self._serialize_message(event, channel_key)
|
||||||
|
connection = await self.connection
|
||||||
|
await connection.publish(full_channel_name, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Failed to publish event to Redis channel {channel_key}. "
|
||||||
|
"Event bus operation will continue without Redis connectivity."
|
||||||
|
)
|
||||||
|
|
||||||
async def listen_events(self, channel_key: str) -> AsyncGenerator[M, None]:
|
async def listen_events(self, channel_key: str) -> AsyncGenerator[M, None]:
|
||||||
pubsub, full_channel_name = self._get_pubsub_channel(
|
pubsub, full_channel_name = self._get_pubsub_channel(
|
||||||
|
|||||||
56
autogpt_platform/backend/backend/data/event_bus_test.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Tests for event_bus graceful degradation when Redis is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.data.event_bus import AsyncRedisEventBus
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvent(BaseModel):
|
||||||
|
"""Test event model."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotificationBus(AsyncRedisEventBus[TestEvent]):
|
||||||
|
"""Test implementation of AsyncRedisEventBus."""
|
||||||
|
|
||||||
|
Model = TestEvent
|
||||||
|
|
||||||
|
@property
|
||||||
|
def event_bus_name(self) -> str:
|
||||||
|
return "test_event_bus"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_event_handles_connection_failure_gracefully():
|
||||||
|
"""Test that publish_event logs exception instead of raising when Redis is unavailable."""
|
||||||
|
bus = TestNotificationBus()
|
||||||
|
event = TestEvent(message="test message")
|
||||||
|
|
||||||
|
# Mock get_redis_async to raise connection error
|
||||||
|
with patch(
|
||||||
|
"backend.data.event_bus.redis.get_redis_async",
|
||||||
|
side_effect=ConnectionError("Authentication required."),
|
||||||
|
):
|
||||||
|
# Should not raise exception
|
||||||
|
await bus.publish_event(event, "test_channel")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_publish_event_works_with_redis_available():
|
||||||
|
"""Test that publish_event works normally when Redis is available."""
|
||||||
|
bus = TestNotificationBus()
|
||||||
|
event = TestEvent(message="test message")
|
||||||
|
|
||||||
|
# Mock successful Redis connection
|
||||||
|
mock_redis = AsyncMock()
|
||||||
|
mock_redis.publish = AsyncMock()
|
||||||
|
|
||||||
|
with patch("backend.data.event_bus.redis.get_redis_async", return_value=mock_redis):
|
||||||
|
await bus.publish_event(event, "test_channel")
|
||||||
|
mock_redis.publish.assert_called_once()
|
||||||
@@ -81,7 +81,10 @@ class ExecutionContext(BaseModel):
|
|||||||
This includes information needed by blocks, sub-graphs, and execution management.
|
This includes information needed by blocks, sub-graphs, and execution management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
safe_mode: bool = True
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
human_in_the_loop_safe_mode: bool = True
|
||||||
|
sensitive_action_safe_mode: bool = False
|
||||||
user_timezone: str = "UTC"
|
user_timezone: str = "UTC"
|
||||||
root_execution_id: Optional[str] = None
|
root_execution_id: Optional[str] = None
|
||||||
parent_execution_id: Optional[str] = None
|
parent_execution_id: Optional[str] = None
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
|
from typing import TYPE_CHECKING, Annotated, Any, Literal, Optional, cast
|
||||||
|
|
||||||
from prisma.enums import SubmissionStatus
|
from prisma.enums import SubmissionStatus
|
||||||
from prisma.models import (
|
from prisma.models import (
|
||||||
@@ -20,7 +20,7 @@ from prisma.types import (
|
|||||||
AgentNodeLinkCreateInput,
|
AgentNodeLinkCreateInput,
|
||||||
StoreListingVersionWhereInput,
|
StoreListingVersionWhereInput,
|
||||||
)
|
)
|
||||||
from pydantic import BaseModel, Field, create_model
|
from pydantic import BaseModel, BeforeValidator, Field, create_model
|
||||||
from pydantic.fields import computed_field
|
from pydantic.fields import computed_field
|
||||||
|
|
||||||
from backend.blocks.agent import AgentExecutorBlock
|
from backend.blocks.agent import AgentExecutorBlock
|
||||||
@@ -62,7 +62,31 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class GraphSettings(BaseModel):
|
class GraphSettings(BaseModel):
|
||||||
human_in_the_loop_safe_mode: bool | None = None
|
# Use Annotated with BeforeValidator to coerce None to default values.
|
||||||
|
# This handles cases where the database has null values for these fields.
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
human_in_the_loop_safe_mode: Annotated[
|
||||||
|
bool, BeforeValidator(lambda v: v if v is not None else True)
|
||||||
|
] = True
|
||||||
|
sensitive_action_safe_mode: Annotated[
|
||||||
|
bool, BeforeValidator(lambda v: v if v is not None else False)
|
||||||
|
] = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_graph(
|
||||||
|
cls,
|
||||||
|
graph: "GraphModel",
|
||||||
|
hitl_safe_mode: bool | None = None,
|
||||||
|
sensitive_action_safe_mode: bool = False,
|
||||||
|
) -> "GraphSettings":
|
||||||
|
# Default to True if not explicitly set
|
||||||
|
if hitl_safe_mode is None:
|
||||||
|
hitl_safe_mode = True
|
||||||
|
return cls(
|
||||||
|
human_in_the_loop_safe_mode=hitl_safe_mode,
|
||||||
|
sensitive_action_safe_mode=sensitive_action_safe_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Link(BaseDbModel):
|
class Link(BaseDbModel):
|
||||||
@@ -244,10 +268,14 @@ class BaseGraph(BaseDbModel):
|
|||||||
return any(
|
return any(
|
||||||
node.block_id
|
node.block_id
|
||||||
for node in self.nodes
|
for node in self.nodes
|
||||||
if (
|
if node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
|
||||||
node.block.block_type == BlockType.HUMAN_IN_THE_LOOP
|
)
|
||||||
or node.block.requires_human_review
|
|
||||||
)
|
@computed_field
|
||||||
|
@property
|
||||||
|
def has_sensitive_action(self) -> bool:
|
||||||
|
return any(
|
||||||
|
node.block_id for node in self.nodes if node.block.is_sensitive_action
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ async def clear_business_understanding(user_id: str) -> bool:
|
|||||||
|
|
||||||
def format_understanding_for_prompt(understanding: BusinessUnderstanding) -> str:
|
def format_understanding_for_prompt(understanding: BusinessUnderstanding) -> str:
|
||||||
"""Format business understanding as text for system prompt injection."""
|
"""Format business understanding as text for system prompt injection."""
|
||||||
|
if not understanding:
|
||||||
|
return ""
|
||||||
sections = []
|
sections = []
|
||||||
|
|
||||||
# User info section
|
# User info section
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ def ensure_embeddings_coverage():
|
|||||||
|
|
||||||
# Process in batches until no more missing embeddings
|
# Process in batches until no more missing embeddings
|
||||||
while True:
|
while True:
|
||||||
result = db_client.backfill_missing_embeddings(batch_size=10)
|
result = db_client.backfill_missing_embeddings(batch_size=100)
|
||||||
|
|
||||||
total_processed += result["processed"]
|
total_processed += result["processed"]
|
||||||
total_success += result["success"]
|
total_success += result["success"]
|
||||||
|
|||||||
@@ -873,11 +873,8 @@ async def add_graph_execution(
|
|||||||
settings = await gdb.get_graph_settings(user_id=user_id, graph_id=graph_id)
|
settings = await gdb.get_graph_settings(user_id=user_id, graph_id=graph_id)
|
||||||
|
|
||||||
execution_context = ExecutionContext(
|
execution_context = ExecutionContext(
|
||||||
safe_mode=(
|
human_in_the_loop_safe_mode=settings.human_in_the_loop_safe_mode,
|
||||||
settings.human_in_the_loop_safe_mode
|
sensitive_action_safe_mode=settings.sensitive_action_safe_mode,
|
||||||
if settings.human_in_the_loop_safe_mode is not None
|
|
||||||
else True
|
|
||||||
),
|
|
||||||
user_timezone=(
|
user_timezone=(
|
||||||
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
|
user.timezone if user.timezone != USER_TIMEZONE_NOT_SET else "UTC"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ async def test_add_graph_execution_is_repeatable(mocker: MockerFixture):
|
|||||||
mock_user.timezone = "UTC"
|
mock_user.timezone = "UTC"
|
||||||
mock_settings = mocker.MagicMock()
|
mock_settings = mocker.MagicMock()
|
||||||
mock_settings.human_in_the_loop_safe_mode = True
|
mock_settings.human_in_the_loop_safe_mode = True
|
||||||
|
mock_settings.sensitive_action_safe_mode = False
|
||||||
|
|
||||||
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
|
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
|
||||||
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
|
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
|
||||||
@@ -651,6 +652,7 @@ async def test_add_graph_execution_with_nodes_to_skip(mocker: MockerFixture):
|
|||||||
mock_user.timezone = "UTC"
|
mock_user.timezone = "UTC"
|
||||||
mock_settings = mocker.MagicMock()
|
mock_settings = mocker.MagicMock()
|
||||||
mock_settings.human_in_the_loop_safe_mode = True
|
mock_settings.human_in_the_loop_safe_mode = True
|
||||||
|
mock_settings.sensitive_action_safe_mode = False
|
||||||
|
|
||||||
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
|
mock_udb.get_user_by_id = mocker.AsyncMock(return_value=mock_user)
|
||||||
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
|
mock_gdb.get_graph_settings = mocker.AsyncMock(return_value=mock_settings)
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
-- CreateExtension
|
-- CreateExtension
|
||||||
-- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first
|
-- Supabase: pgvector must be enabled via Dashboard → Database → Extensions first
|
||||||
-- Create in public schema so vector type is available across all schemas
|
-- 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 $$
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_schema_name text;
|
||||||
|
vector_schema text;
|
||||||
BEGIN
|
BEGIN
|
||||||
CREATE EXTENSION IF NOT EXISTS "vector" WITH SCHEMA "public";
|
-- Get the current schema from search_path
|
||||||
EXCEPTION WHEN OTHERS THEN
|
SELECT current_schema() INTO current_schema_name;
|
||||||
RAISE NOTICE 'vector extension not available or already exists, skipping';
|
|
||||||
|
-- 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 $$;
|
END $$;
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
@@ -19,7 +45,7 @@ CREATE TABLE "UnifiedContentEmbedding" (
|
|||||||
"contentType" "ContentType" NOT NULL,
|
"contentType" "ContentType" NOT NULL,
|
||||||
"contentId" TEXT NOT NULL,
|
"contentId" TEXT NOT NULL,
|
||||||
"userId" TEXT,
|
"userId" TEXT,
|
||||||
"embedding" public.vector(1536) NOT NULL,
|
"embedding" vector(1536) NOT NULL,
|
||||||
"searchableText" TEXT NOT NULL,
|
"searchableText" TEXT NOT NULL,
|
||||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
@@ -45,4 +71,4 @@ CREATE UNIQUE INDEX "UnifiedContentEmbedding_contentType_contentId_userId_key" O
|
|||||||
-- Uses cosine distance operator (<=>), which matches the query in hybrid_search.py
|
-- Uses cosine distance operator (<=>), which matches the query in hybrid_search.py
|
||||||
-- Note: Drop first in case Prisma created a btree index (Prisma doesn't support HNSW)
|
-- Note: Drop first in case Prisma created a btree index (Prisma doesn't support HNSW)
|
||||||
DROP INDEX IF EXISTS "UnifiedContentEmbedding_embedding_idx";
|
DROP INDEX IF EXISTS "UnifiedContentEmbedding_embedding_idx";
|
||||||
CREATE INDEX "UnifiedContentEmbedding_embedding_idx" ON "UnifiedContentEmbedding" USING hnsw ("embedding" public.vector_cosine_ops);
|
CREATE INDEX "UnifiedContentEmbedding_embedding_idx" ON "UnifiedContentEmbedding" USING hnsw ("embedding" vector_cosine_ops);
|
||||||
|
|||||||
@@ -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";
|
|
||||||
998
autogpt_platform/backend/scripts/generate_block_docs.py
Normal file
@@ -0,0 +1,998 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Block Documentation Generator
|
||||||
|
|
||||||
|
Generates markdown documentation for all blocks from code introspection.
|
||||||
|
Preserves manually-written content between marker comments.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Generate all docs
|
||||||
|
poetry run python scripts/generate_block_docs.py
|
||||||
|
|
||||||
|
# Check mode for CI (exits 1 if stale)
|
||||||
|
poetry run python scripts/generate_block_docs.py --check
|
||||||
|
|
||||||
|
# Verbose output
|
||||||
|
poetry run python scripts/generate_block_docs.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Add backend to path for imports
|
||||||
|
backend_dir = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(backend_dir))
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default output directory relative to repo root
|
||||||
|
DEFAULT_OUTPUT_DIR = (
|
||||||
|
Path(__file__).parent.parent.parent.parent
|
||||||
|
/ "docs"
|
||||||
|
/ "integrations"
|
||||||
|
/ "block-integrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FieldDoc:
|
||||||
|
"""Documentation for a single input/output field."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
type_str: str
|
||||||
|
required: bool
|
||||||
|
default: Any = None
|
||||||
|
advanced: bool = False
|
||||||
|
hidden: bool = False
|
||||||
|
placeholder: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BlockDoc:
|
||||||
|
"""Documentation data extracted from a block."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
class_name: str
|
||||||
|
description: str
|
||||||
|
categories: list[str]
|
||||||
|
category_descriptions: dict[str, str]
|
||||||
|
inputs: list[FieldDoc]
|
||||||
|
outputs: list[FieldDoc]
|
||||||
|
block_type: str
|
||||||
|
source_file: str
|
||||||
|
contributors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# Category to human-readable name mapping
|
||||||
|
CATEGORY_DISPLAY_NAMES = {
|
||||||
|
"AI": "AI and Language Models",
|
||||||
|
"BASIC": "Basic Operations",
|
||||||
|
"TEXT": "Text Processing",
|
||||||
|
"SEARCH": "Search and Information Retrieval",
|
||||||
|
"SOCIAL": "Social Media and Content",
|
||||||
|
"DEVELOPER_TOOLS": "Developer Tools",
|
||||||
|
"DATA": "Data Processing",
|
||||||
|
"LOGIC": "Logic and Control Flow",
|
||||||
|
"COMMUNICATION": "Communication",
|
||||||
|
"INPUT": "Input/Output",
|
||||||
|
"OUTPUT": "Input/Output",
|
||||||
|
"MULTIMEDIA": "Media Generation",
|
||||||
|
"PRODUCTIVITY": "Productivity",
|
||||||
|
"HARDWARE": "Hardware",
|
||||||
|
"AGENT": "Agent Integration",
|
||||||
|
"CRM": "CRM Services",
|
||||||
|
"SAFETY": "AI Safety",
|
||||||
|
"ISSUE_TRACKING": "Issue Tracking",
|
||||||
|
"MARKETING": "Marketing",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Category to doc file mapping (for grouping related blocks)
|
||||||
|
CATEGORY_FILE_MAP = {
|
||||||
|
"BASIC": "basic",
|
||||||
|
"TEXT": "text",
|
||||||
|
"AI": "llm",
|
||||||
|
"SEARCH": "search",
|
||||||
|
"DATA": "data",
|
||||||
|
"LOGIC": "logic",
|
||||||
|
"COMMUNICATION": "communication",
|
||||||
|
"MULTIMEDIA": "multimedia",
|
||||||
|
"PRODUCTIVITY": "productivity",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def class_name_to_display_name(class_name: str) -> str:
|
||||||
|
"""Convert BlockClassName to 'Block Class Name'."""
|
||||||
|
# Remove 'Block' suffix (only at the end, not all occurrences)
|
||||||
|
name = class_name.removesuffix("Block")
|
||||||
|
# Insert space before capitals
|
||||||
|
name = re.sub(r"([a-z])([A-Z])", r"\1 \2", name)
|
||||||
|
# Handle consecutive capitals (e.g., 'HTTPRequest' -> 'HTTP Request')
|
||||||
|
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", name)
|
||||||
|
return name.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def type_to_readable(type_schema: dict[str, Any] | Any) -> str:
|
||||||
|
"""Convert JSON schema type to human-readable string."""
|
||||||
|
if not isinstance(type_schema, dict):
|
||||||
|
return str(type_schema) if type_schema else "Any"
|
||||||
|
|
||||||
|
if "anyOf" in type_schema:
|
||||||
|
# Union type - show options
|
||||||
|
any_of = type_schema["anyOf"]
|
||||||
|
if not isinstance(any_of, list):
|
||||||
|
return "Any"
|
||||||
|
options = []
|
||||||
|
for opt in any_of:
|
||||||
|
if isinstance(opt, dict) and opt.get("type") == "null":
|
||||||
|
continue
|
||||||
|
options.append(type_to_readable(opt))
|
||||||
|
if not options:
|
||||||
|
return "None"
|
||||||
|
if len(options) == 1:
|
||||||
|
return options[0]
|
||||||
|
return " | ".join(options)
|
||||||
|
|
||||||
|
if "allOf" in type_schema:
|
||||||
|
all_of = type_schema["allOf"]
|
||||||
|
if not isinstance(all_of, list) or not all_of:
|
||||||
|
return "Any"
|
||||||
|
return type_to_readable(all_of[0])
|
||||||
|
|
||||||
|
schema_type = type_schema.get("type")
|
||||||
|
|
||||||
|
if schema_type == "array":
|
||||||
|
items = type_schema.get("items", {})
|
||||||
|
item_type = type_to_readable(items)
|
||||||
|
return f"List[{item_type}]"
|
||||||
|
|
||||||
|
if schema_type == "object":
|
||||||
|
if "additionalProperties" in type_schema:
|
||||||
|
additional_props = type_schema["additionalProperties"]
|
||||||
|
# additionalProperties: true means any value type is allowed
|
||||||
|
if additional_props is True:
|
||||||
|
return "Dict[str, Any]"
|
||||||
|
value_type = type_to_readable(additional_props)
|
||||||
|
return f"Dict[str, {value_type}]"
|
||||||
|
# Check if it's a specific model
|
||||||
|
title = type_schema.get("title", "Object")
|
||||||
|
return title
|
||||||
|
|
||||||
|
if schema_type == "string":
|
||||||
|
if "enum" in type_schema:
|
||||||
|
return " | ".join(f'"{v}"' for v in type_schema["enum"])
|
||||||
|
if "format" in type_schema:
|
||||||
|
return f"str ({type_schema['format']})"
|
||||||
|
return "str"
|
||||||
|
|
||||||
|
if schema_type == "integer":
|
||||||
|
return "int"
|
||||||
|
|
||||||
|
if schema_type == "number":
|
||||||
|
return "float"
|
||||||
|
|
||||||
|
if schema_type == "boolean":
|
||||||
|
return "bool"
|
||||||
|
|
||||||
|
if schema_type == "null":
|
||||||
|
return "None"
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
return type_schema.get("title", schema_type or "Any")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_get(d: Any, key: str, default: Any = None) -> Any:
|
||||||
|
"""Safely get a value from a dict-like object."""
|
||||||
|
if isinstance(d, dict):
|
||||||
|
return d.get(key, default)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def file_path_to_title(file_path: str) -> str:
|
||||||
|
"""Convert file path to a readable title.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"github/issues.md" -> "GitHub Issues"
|
||||||
|
"basic.md" -> "Basic"
|
||||||
|
"llm.md" -> "LLM"
|
||||||
|
"google/sheets.md" -> "Google Sheets"
|
||||||
|
"""
|
||||||
|
# Special case replacements (applied after title casing)
|
||||||
|
TITLE_FIXES = {
|
||||||
|
"Llm": "LLM",
|
||||||
|
"Github": "GitHub",
|
||||||
|
"Api": "API",
|
||||||
|
"Ai": "AI",
|
||||||
|
"Oauth": "OAuth",
|
||||||
|
"Url": "URL",
|
||||||
|
"Ci": "CI",
|
||||||
|
"Pr": "PR",
|
||||||
|
"Gmb": "GMB", # Google My Business
|
||||||
|
"Hubspot": "HubSpot",
|
||||||
|
"Linkedin": "LinkedIn",
|
||||||
|
"Tiktok": "TikTok",
|
||||||
|
"Youtube": "YouTube",
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply_fixes(text: str) -> str:
|
||||||
|
# Split into words, fix each word, rejoin
|
||||||
|
words = text.split()
|
||||||
|
fixed_words = [TITLE_FIXES.get(word, word) for word in words]
|
||||||
|
return " ".join(fixed_words)
|
||||||
|
|
||||||
|
path = Path(file_path)
|
||||||
|
name = path.stem # e.g., "issues" or "sheets"
|
||||||
|
|
||||||
|
# Get parent dir if exists
|
||||||
|
parent = path.parent.name if path.parent.name != "." else None
|
||||||
|
|
||||||
|
# Title case and apply fixes
|
||||||
|
if parent:
|
||||||
|
parent_title = apply_fixes(parent.replace("_", " ").title())
|
||||||
|
name_title = apply_fixes(name.replace("_", " ").title())
|
||||||
|
return f"{parent_title} {name_title}"
|
||||||
|
return apply_fixes(name.replace("_", " ").title())
|
||||||
|
|
||||||
|
|
||||||
|
def extract_block_doc(block_cls: type) -> BlockDoc:
|
||||||
|
"""Extract documentation data from a block class."""
|
||||||
|
block = block_cls.create()
|
||||||
|
|
||||||
|
# Get source file
|
||||||
|
try:
|
||||||
|
source_file = inspect.getfile(block_cls)
|
||||||
|
# Make relative to blocks directory
|
||||||
|
blocks_dir = Path(source_file).parent
|
||||||
|
while blocks_dir.name != "blocks" and blocks_dir.parent != blocks_dir:
|
||||||
|
blocks_dir = blocks_dir.parent
|
||||||
|
source_file = str(Path(source_file).relative_to(blocks_dir.parent))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
source_file = "unknown"
|
||||||
|
|
||||||
|
# Extract input fields
|
||||||
|
input_schema = block.input_schema.jsonschema()
|
||||||
|
input_properties = safe_get(input_schema, "properties", {})
|
||||||
|
if not isinstance(input_properties, dict):
|
||||||
|
input_properties = {}
|
||||||
|
required_raw = safe_get(input_schema, "required", [])
|
||||||
|
# Handle edge cases where required might not be a list
|
||||||
|
if isinstance(required_raw, (list, set, tuple)):
|
||||||
|
required_inputs = set(required_raw)
|
||||||
|
else:
|
||||||
|
required_inputs = set()
|
||||||
|
|
||||||
|
inputs = []
|
||||||
|
for field_name, field_schema in input_properties.items():
|
||||||
|
if not isinstance(field_schema, dict):
|
||||||
|
continue
|
||||||
|
# Skip credentials fields in docs (they're auto-handled)
|
||||||
|
if "credentials" in field_name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
inputs.append(
|
||||||
|
FieldDoc(
|
||||||
|
name=field_name,
|
||||||
|
description=safe_get(field_schema, "description", ""),
|
||||||
|
type_str=type_to_readable(field_schema),
|
||||||
|
required=field_name in required_inputs,
|
||||||
|
default=safe_get(field_schema, "default"),
|
||||||
|
advanced=safe_get(field_schema, "advanced", False) or False,
|
||||||
|
hidden=safe_get(field_schema, "hidden", False) or False,
|
||||||
|
placeholder=safe_get(field_schema, "placeholder"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract output fields
|
||||||
|
output_schema = block.output_schema.jsonschema()
|
||||||
|
output_properties = safe_get(output_schema, "properties", {})
|
||||||
|
if not isinstance(output_properties, dict):
|
||||||
|
output_properties = {}
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
for field_name, field_schema in output_properties.items():
|
||||||
|
if not isinstance(field_schema, dict):
|
||||||
|
continue
|
||||||
|
outputs.append(
|
||||||
|
FieldDoc(
|
||||||
|
name=field_name,
|
||||||
|
description=safe_get(field_schema, "description", ""),
|
||||||
|
type_str=type_to_readable(field_schema),
|
||||||
|
required=True, # Outputs are always produced
|
||||||
|
hidden=safe_get(field_schema, "hidden", False) or False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get category info (sort for deterministic ordering since it's a set)
|
||||||
|
categories = []
|
||||||
|
category_descriptions = {}
|
||||||
|
for cat in sorted(block.categories, key=lambda c: c.name):
|
||||||
|
categories.append(cat.name)
|
||||||
|
category_descriptions[cat.name] = cat.value
|
||||||
|
|
||||||
|
# Get contributors
|
||||||
|
contributors = []
|
||||||
|
for contrib in block.contributors:
|
||||||
|
contributors.append(contrib.name if hasattr(contrib, "name") else str(contrib))
|
||||||
|
|
||||||
|
return BlockDoc(
|
||||||
|
id=block.id,
|
||||||
|
name=class_name_to_display_name(block.name),
|
||||||
|
class_name=block.name,
|
||||||
|
description=block.description,
|
||||||
|
categories=categories,
|
||||||
|
category_descriptions=category_descriptions,
|
||||||
|
inputs=inputs,
|
||||||
|
outputs=outputs,
|
||||||
|
block_type=block.block_type.value,
|
||||||
|
source_file=source_file,
|
||||||
|
contributors=contributors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_anchor(name: str) -> str:
|
||||||
|
"""Generate markdown anchor from block name."""
|
||||||
|
return name.lower().replace(" ", "-").replace("(", "").replace(")", "")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_manual_content(existing_content: str) -> dict[str, str]:
|
||||||
|
"""Extract content between MANUAL markers from existing file."""
|
||||||
|
manual_sections = {}
|
||||||
|
|
||||||
|
# Pattern: <!-- MANUAL: section_name -->content<!-- END MANUAL -->
|
||||||
|
pattern = r"<!-- MANUAL: (\w+) -->\s*(.*?)\s*<!-- END MANUAL -->"
|
||||||
|
matches = re.findall(pattern, existing_content, re.DOTALL)
|
||||||
|
|
||||||
|
for section_name, content in matches:
|
||||||
|
manual_sections[section_name] = content.strip()
|
||||||
|
|
||||||
|
return manual_sections
|
||||||
|
|
||||||
|
|
||||||
|
def generate_block_markdown(
|
||||||
|
block: BlockDoc,
|
||||||
|
manual_content: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Generate markdown documentation for a single block."""
|
||||||
|
manual_content = manual_content or {}
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# All blocks use ## heading, sections use ### (consistent siblings)
|
||||||
|
lines.append(f"## {block.name}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# What it is (full description)
|
||||||
|
lines.append("### What it is")
|
||||||
|
lines.append(block.description or "No description available.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# How it works (manual section)
|
||||||
|
lines.append("### How it works")
|
||||||
|
how_it_works = manual_content.get(
|
||||||
|
"how_it_works", "_Add technical explanation here._"
|
||||||
|
)
|
||||||
|
lines.append("<!-- MANUAL: how_it_works -->")
|
||||||
|
lines.append(how_it_works)
|
||||||
|
lines.append("<!-- END MANUAL -->")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Inputs table (auto-generated)
|
||||||
|
visible_inputs = [f for f in block.inputs if not f.hidden]
|
||||||
|
if visible_inputs:
|
||||||
|
lines.append("### Inputs")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Input | Description | Type | Required |")
|
||||||
|
lines.append("|-------|-------------|------|----------|")
|
||||||
|
for inp in visible_inputs:
|
||||||
|
required = "Yes" if inp.required else "No"
|
||||||
|
desc = inp.description or "-"
|
||||||
|
type_str = inp.type_str or "-"
|
||||||
|
# Normalize newlines and escape pipes for valid table syntax
|
||||||
|
desc = desc.replace("\n", " ").replace("|", "\\|")
|
||||||
|
type_str = type_str.replace("|", "\\|")
|
||||||
|
lines.append(f"| {inp.name} | {desc} | {type_str} | {required} |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Outputs table (auto-generated)
|
||||||
|
visible_outputs = [f for f in block.outputs if not f.hidden]
|
||||||
|
if visible_outputs:
|
||||||
|
lines.append("### Outputs")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Output | Description | Type |")
|
||||||
|
lines.append("|--------|-------------|------|")
|
||||||
|
for out in visible_outputs:
|
||||||
|
desc = out.description or "-"
|
||||||
|
type_str = out.type_str or "-"
|
||||||
|
# Normalize newlines and escape pipes for valid table syntax
|
||||||
|
desc = desc.replace("\n", " ").replace("|", "\\|")
|
||||||
|
type_str = type_str.replace("|", "\\|")
|
||||||
|
lines.append(f"| {out.name} | {desc} | {type_str} |")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Possible use case (manual section)
|
||||||
|
lines.append("### Possible use case")
|
||||||
|
use_case = manual_content.get("use_case", "_Add practical use case examples here._")
|
||||||
|
lines.append("<!-- MANUAL: use_case -->")
|
||||||
|
lines.append(use_case)
|
||||||
|
lines.append("<!-- END MANUAL -->")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Optional per-block extras (only include if has content)
|
||||||
|
extras = manual_content.get("extras", "")
|
||||||
|
if extras:
|
||||||
|
lines.append("<!-- MANUAL: extras -->")
|
||||||
|
lines.append(extras)
|
||||||
|
lines.append("<!-- END MANUAL -->")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def get_block_file_mapping(blocks: list[BlockDoc]) -> dict[str, list[BlockDoc]]:
|
||||||
|
"""
|
||||||
|
Map blocks to their documentation files.
|
||||||
|
|
||||||
|
Returns dict of {relative_file_path: [blocks]}
|
||||||
|
"""
|
||||||
|
file_mapping = defaultdict(list)
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
# Determine file path based on source file or category
|
||||||
|
source_path = Path(block.source_file)
|
||||||
|
|
||||||
|
# If source is in a subdirectory (e.g., google/gmail.py), use that structure
|
||||||
|
if len(source_path.parts) > 2: # blocks/subdir/file.py
|
||||||
|
subdir = source_path.parts[1] # e.g., "google"
|
||||||
|
# Use the Python filename as the md filename
|
||||||
|
md_file = source_path.stem + ".md" # e.g., "gmail.md"
|
||||||
|
file_path = f"{subdir}/{md_file}"
|
||||||
|
else:
|
||||||
|
# Use category-based grouping for top-level blocks
|
||||||
|
primary_category = block.categories[0] if block.categories else "BASIC"
|
||||||
|
file_name = CATEGORY_FILE_MAP.get(primary_category, "misc")
|
||||||
|
file_path = f"{file_name}.md"
|
||||||
|
|
||||||
|
file_mapping[file_path].append(block)
|
||||||
|
|
||||||
|
return dict(file_mapping)
|
||||||
|
|
||||||
|
|
||||||
|
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('{% 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"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"* [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."
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Group blocks by category
|
||||||
|
by_category = defaultdict(list)
|
||||||
|
for block in blocks:
|
||||||
|
primary_cat = block.categories[0] if block.categories else "BASIC"
|
||||||
|
by_category[primary_cat].append(block)
|
||||||
|
|
||||||
|
# Sort categories
|
||||||
|
category_order = [
|
||||||
|
"BASIC",
|
||||||
|
"DATA",
|
||||||
|
"TEXT",
|
||||||
|
"AI",
|
||||||
|
"SEARCH",
|
||||||
|
"SOCIAL",
|
||||||
|
"COMMUNICATION",
|
||||||
|
"DEVELOPER_TOOLS",
|
||||||
|
"MULTIMEDIA",
|
||||||
|
"PRODUCTIVITY",
|
||||||
|
"LOGIC",
|
||||||
|
"INPUT",
|
||||||
|
"OUTPUT",
|
||||||
|
"AGENT",
|
||||||
|
"CRM",
|
||||||
|
"SAFETY",
|
||||||
|
"ISSUE_TRACKING",
|
||||||
|
"HARDWARE",
|
||||||
|
"MARKETING",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Track emitted display names to avoid duplicate headers
|
||||||
|
# (e.g., INPUT and OUTPUT both map to "Input/Output")
|
||||||
|
emitted_display_names: set[str] = set()
|
||||||
|
|
||||||
|
for category in category_order:
|
||||||
|
if category not in by_category:
|
||||||
|
continue
|
||||||
|
|
||||||
|
display_name = CATEGORY_DISPLAY_NAMES.get(category, category)
|
||||||
|
|
||||||
|
# Collect all blocks for this display name (may span multiple categories)
|
||||||
|
if display_name in emitted_display_names:
|
||||||
|
# Already emitted header, just add rows to existing table
|
||||||
|
# Find the position before the last empty line and insert rows
|
||||||
|
cat_blocks = sorted(by_category[category], key=lambda b: b.name)
|
||||||
|
# Remove the trailing empty line, add rows, then re-add empty line
|
||||||
|
lines.pop()
|
||||||
|
for block in cat_blocks:
|
||||||
|
file_mapping = get_block_file_mapping([block])
|
||||||
|
file_path = list(file_mapping.keys())[0]
|
||||||
|
anchor = generate_anchor(block.name)
|
||||||
|
short_desc = (
|
||||||
|
block.description.split(".")[0]
|
||||||
|
if block.description
|
||||||
|
else "No description"
|
||||||
|
)
|
||||||
|
short_desc = short_desc.replace("\n", " ").replace("|", "\\|")
|
||||||
|
link_path = f"{block_dir_prefix}{file_path}"
|
||||||
|
lines.append(f"| [{block.name}]({link_path}#{anchor}) | {short_desc} |")
|
||||||
|
lines.append("")
|
||||||
|
continue
|
||||||
|
|
||||||
|
emitted_display_names.add(display_name)
|
||||||
|
cat_blocks = sorted(by_category[category], key=lambda b: b.name)
|
||||||
|
|
||||||
|
lines.append(f"## {display_name}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Block Name | Description |")
|
||||||
|
lines.append("|------------|-------------|")
|
||||||
|
|
||||||
|
for block in cat_blocks:
|
||||||
|
# Determine link path
|
||||||
|
file_mapping = get_block_file_mapping([block])
|
||||||
|
file_path = list(file_mapping.keys())[0]
|
||||||
|
anchor = generate_anchor(block.name)
|
||||||
|
|
||||||
|
# Short description (first sentence)
|
||||||
|
short_desc = (
|
||||||
|
block.description.split(".")[0]
|
||||||
|
if block.description
|
||||||
|
else "No description"
|
||||||
|
)
|
||||||
|
short_desc = short_desc.replace("\n", " ").replace("|", "\\|")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
block_classes = load_all_blocks()
|
||||||
|
blocks = []
|
||||||
|
|
||||||
|
for _block_id, block_cls in block_classes.items():
|
||||||
|
try:
|
||||||
|
block_doc = extract_block_doc(block_cls)
|
||||||
|
blocks.append(block_doc)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to extract docs for {block_cls.__name__}: {e}")
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def write_block_docs(
|
||||||
|
output_dir: Path,
|
||||||
|
blocks: list[BlockDoc],
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Write block documentation files.
|
||||||
|
|
||||||
|
Returns dict of {file_path: content} for all generated files.
|
||||||
|
"""
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
file_mapping = get_block_file_mapping(blocks)
|
||||||
|
generated_files = {}
|
||||||
|
|
||||||
|
for file_path, file_blocks in file_mapping.items():
|
||||||
|
full_path = output_dir / file_path
|
||||||
|
|
||||||
|
# Create subdirectories if needed
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Load existing content for manual section preservation
|
||||||
|
existing_content = ""
|
||||||
|
if full_path.exists():
|
||||||
|
existing_content = full_path.read_text()
|
||||||
|
|
||||||
|
# Always generate title from file path (with fixes applied)
|
||||||
|
file_title = file_path_to_title(file_path)
|
||||||
|
|
||||||
|
# Extract existing file description if present (preserve manual content)
|
||||||
|
file_header_pattern = (
|
||||||
|
r"^# .+?\n<!-- MANUAL: file_description -->\n(.*?)\n<!-- END MANUAL -->"
|
||||||
|
)
|
||||||
|
file_header_match = re.search(file_header_pattern, existing_content, re.DOTALL)
|
||||||
|
|
||||||
|
if file_header_match:
|
||||||
|
file_description = file_header_match.group(1)
|
||||||
|
else:
|
||||||
|
file_description = "_Add a description of this category of blocks._"
|
||||||
|
|
||||||
|
# Generate file header
|
||||||
|
file_header = f"# {file_title}\n"
|
||||||
|
file_header += "<!-- MANUAL: file_description -->\n"
|
||||||
|
file_header += f"{file_description}\n"
|
||||||
|
file_header += "<!-- END MANUAL -->\n"
|
||||||
|
|
||||||
|
# Generate content for each block
|
||||||
|
content_parts = []
|
||||||
|
for block in sorted(file_blocks, key=lambda b: b.name):
|
||||||
|
# Extract manual content specific to this block
|
||||||
|
# Match block heading (h2) and capture until --- separator
|
||||||
|
block_pattern = rf"(?:^|\n)## {re.escape(block.name)}\s*\n(.*?)(?=\n---|\Z)"
|
||||||
|
block_match = re.search(block_pattern, existing_content, re.DOTALL)
|
||||||
|
if block_match:
|
||||||
|
manual_content = extract_manual_content(block_match.group(1))
|
||||||
|
else:
|
||||||
|
manual_content = {}
|
||||||
|
|
||||||
|
content_parts.append(
|
||||||
|
generate_block_markdown(
|
||||||
|
block,
|
||||||
|
manual_content,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add file-level additional_content section if present
|
||||||
|
file_additional = extract_manual_content(existing_content).get(
|
||||||
|
"additional_content", ""
|
||||||
|
)
|
||||||
|
if file_additional:
|
||||||
|
content_parts.append("<!-- MANUAL: additional_content -->")
|
||||||
|
content_parts.append(file_additional)
|
||||||
|
content_parts.append("<!-- END MANUAL -->")
|
||||||
|
content_parts.append("")
|
||||||
|
|
||||||
|
full_content = file_header + "\n" + "\n".join(content_parts)
|
||||||
|
generated_files[str(file_path)] = full_content
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f" Writing {file_path} ({len(file_blocks)} blocks)")
|
||||||
|
|
||||||
|
full_path.write_text(full_content)
|
||||||
|
|
||||||
|
# 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) 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
|
||||||
|
|
||||||
|
|
||||||
|
def check_docs_in_sync(output_dir: Path, blocks: list[BlockDoc]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if generated docs match existing docs.
|
||||||
|
|
||||||
|
Returns True if in sync, False otherwise.
|
||||||
|
"""
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
file_mapping = get_block_file_mapping(blocks)
|
||||||
|
|
||||||
|
all_match = True
|
||||||
|
out_of_sync_details: list[tuple[str, list[str]]] = []
|
||||||
|
|
||||||
|
for file_path, file_blocks in file_mapping.items():
|
||||||
|
full_path = output_dir / file_path
|
||||||
|
|
||||||
|
if not full_path.exists():
|
||||||
|
block_names = [b.name for b in sorted(file_blocks, key=lambda b: b.name)]
|
||||||
|
print(f"MISSING: {file_path}")
|
||||||
|
print(f" Blocks: {', '.join(block_names)}")
|
||||||
|
out_of_sync_details.append((file_path, block_names))
|
||||||
|
all_match = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing_content = full_path.read_text()
|
||||||
|
|
||||||
|
# Always generate title from file path (with fixes applied)
|
||||||
|
file_title = file_path_to_title(file_path)
|
||||||
|
|
||||||
|
# Extract existing file description if present (preserve manual content)
|
||||||
|
file_header_pattern = (
|
||||||
|
r"^# .+?\n<!-- MANUAL: file_description -->\n(.*?)\n<!-- END MANUAL -->"
|
||||||
|
)
|
||||||
|
file_header_match = re.search(file_header_pattern, existing_content, re.DOTALL)
|
||||||
|
|
||||||
|
if file_header_match:
|
||||||
|
file_description = file_header_match.group(1)
|
||||||
|
else:
|
||||||
|
file_description = "_Add a description of this category of blocks._"
|
||||||
|
|
||||||
|
# Generate expected file header
|
||||||
|
file_header = f"# {file_title}\n"
|
||||||
|
file_header += "<!-- MANUAL: file_description -->\n"
|
||||||
|
file_header += f"{file_description}\n"
|
||||||
|
file_header += "<!-- END MANUAL -->\n"
|
||||||
|
|
||||||
|
# Extract manual content from existing file
|
||||||
|
manual_sections_by_block = {}
|
||||||
|
for block in file_blocks:
|
||||||
|
block_pattern = rf"(?:^|\n)## {re.escape(block.name)}\s*\n(.*?)(?=\n---|\Z)"
|
||||||
|
block_match = re.search(block_pattern, existing_content, re.DOTALL)
|
||||||
|
if block_match:
|
||||||
|
manual_sections_by_block[block.name] = extract_manual_content(
|
||||||
|
block_match.group(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate expected content and check each block individually
|
||||||
|
content_parts = []
|
||||||
|
mismatched_blocks = []
|
||||||
|
for block in sorted(file_blocks, key=lambda b: b.name):
|
||||||
|
manual_content = manual_sections_by_block.get(block.name, {})
|
||||||
|
expected_block_content = generate_block_markdown(
|
||||||
|
block,
|
||||||
|
manual_content,
|
||||||
|
)
|
||||||
|
content_parts.append(expected_block_content)
|
||||||
|
|
||||||
|
# Check if this specific block's section exists and matches
|
||||||
|
# Include the --- separator to match generate_block_markdown output
|
||||||
|
block_pattern = rf"(?:^|\n)(## {re.escape(block.name)}\s*\n.*?\n---\n)"
|
||||||
|
block_match = re.search(block_pattern, existing_content, re.DOTALL)
|
||||||
|
if not block_match:
|
||||||
|
mismatched_blocks.append(f"{block.name} (missing)")
|
||||||
|
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("<!-- MANUAL: additional_content -->")
|
||||||
|
content_parts.append(file_additional)
|
||||||
|
content_parts.append("<!-- END MANUAL -->")
|
||||||
|
content_parts.append("")
|
||||||
|
|
||||||
|
expected_content = file_header + "\n" + "\n".join(content_parts)
|
||||||
|
|
||||||
|
if existing_content.strip() != expected_content.strip():
|
||||||
|
print(f"OUT OF SYNC: {file_path}")
|
||||||
|
if mismatched_blocks:
|
||||||
|
print(f" Affected blocks: {', '.join(mismatched_blocks)}")
|
||||||
|
out_of_sync_details.append((file_path, mismatched_blocks))
|
||||||
|
all_match = False
|
||||||
|
|
||||||
|
# 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, block_dir_prefix)
|
||||||
|
if existing_overview.strip() != expected_overview.strip():
|
||||||
|
print("OUT OF SYNC: README.md (overview)")
|
||||||
|
print(" The blocks overview table needs regeneration")
|
||||||
|
out_of_sync_details.append(("README.md", ["overview table"]))
|
||||||
|
all_match = False
|
||||||
|
else:
|
||||||
|
print("MISSING: README.md (overview)")
|
||||||
|
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._",
|
||||||
|
"_Add technical explanation here._",
|
||||||
|
"_Add practical use case examples here._",
|
||||||
|
]
|
||||||
|
files_with_unfilled = []
|
||||||
|
for file_path in file_mapping.keys():
|
||||||
|
full_path = output_dir / file_path
|
||||||
|
if full_path.exists():
|
||||||
|
content = full_path.read_text()
|
||||||
|
unfilled_count = sum(1 for p in unfilled_patterns if p in content)
|
||||||
|
if unfilled_count > 0:
|
||||||
|
files_with_unfilled.append((file_path, unfilled_count))
|
||||||
|
|
||||||
|
if files_with_unfilled:
|
||||||
|
print("\nWARNING: Files with unfilled manual sections:")
|
||||||
|
for file_path, count in sorted(files_with_unfilled):
|
||||||
|
print(f" {file_path}: {count} unfilled section(s)")
|
||||||
|
print(
|
||||||
|
f"\nTotal: {len(files_with_unfilled)} files with unfilled manual sections"
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_match
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate block documentation from code introspection"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_OUTPUT_DIR,
|
||||||
|
help="Output directory for generated docs",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check",
|
||||||
|
action="store_true",
|
||||||
|
help="Check if docs are in sync (for CI), exit 1 if not",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="Verbose output",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||||
|
format="%(levelname)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Loading blocks...")
|
||||||
|
blocks = load_all_blocks_for_docs()
|
||||||
|
print(f"Found {len(blocks)} blocks")
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
print(f"Checking docs in {args.output_dir}...")
|
||||||
|
in_sync = check_docs_in_sync(args.output_dir, blocks)
|
||||||
|
if in_sync:
|
||||||
|
print("All documentation is in sync!")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Documentation is out of sync!")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\nTo fix this, run one of the following:")
|
||||||
|
print("\n Option 1 - Run locally:")
|
||||||
|
print(
|
||||||
|
" cd autogpt_platform/backend && poetry run python scripts/generate_block_docs.py"
|
||||||
|
)
|
||||||
|
print("\n Option 2 - Ask Claude Code to run it:")
|
||||||
|
print(' "Run the block docs generator script to sync documentation"')
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f"Generating docs to {args.output_dir}...")
|
||||||
|
write_block_docs(
|
||||||
|
args.output_dir,
|
||||||
|
blocks,
|
||||||
|
verbose=args.verbose,
|
||||||
|
)
|
||||||
|
print("Done!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
208
autogpt_platform/backend/scripts/test_generate_block_docs.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the block documentation generator."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts.generate_block_docs import (
|
||||||
|
class_name_to_display_name,
|
||||||
|
extract_manual_content,
|
||||||
|
generate_anchor,
|
||||||
|
type_to_readable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassNameToDisplayName:
|
||||||
|
"""Tests for class_name_to_display_name function."""
|
||||||
|
|
||||||
|
def test_simple_block_name(self):
|
||||||
|
assert class_name_to_display_name("PrintBlock") == "Print"
|
||||||
|
|
||||||
|
def test_multi_word_block_name(self):
|
||||||
|
assert class_name_to_display_name("GetWeatherBlock") == "Get Weather"
|
||||||
|
|
||||||
|
def test_consecutive_capitals(self):
|
||||||
|
assert class_name_to_display_name("HTTPRequestBlock") == "HTTP Request"
|
||||||
|
|
||||||
|
def test_ai_prefix(self):
|
||||||
|
assert class_name_to_display_name("AIConditionBlock") == "AI Condition"
|
||||||
|
|
||||||
|
def test_no_block_suffix(self):
|
||||||
|
assert class_name_to_display_name("SomeClass") == "Some Class"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypeToReadable:
|
||||||
|
"""Tests for type_to_readable function."""
|
||||||
|
|
||||||
|
def test_string_type(self):
|
||||||
|
assert type_to_readable({"type": "string"}) == "str"
|
||||||
|
|
||||||
|
def test_integer_type(self):
|
||||||
|
assert type_to_readable({"type": "integer"}) == "int"
|
||||||
|
|
||||||
|
def test_number_type(self):
|
||||||
|
assert type_to_readable({"type": "number"}) == "float"
|
||||||
|
|
||||||
|
def test_boolean_type(self):
|
||||||
|
assert type_to_readable({"type": "boolean"}) == "bool"
|
||||||
|
|
||||||
|
def test_array_type(self):
|
||||||
|
result = type_to_readable({"type": "array", "items": {"type": "string"}})
|
||||||
|
assert result == "List[str]"
|
||||||
|
|
||||||
|
def test_object_type(self):
|
||||||
|
result = type_to_readable({"type": "object", "title": "MyModel"})
|
||||||
|
assert result == "MyModel"
|
||||||
|
|
||||||
|
def test_anyof_with_null(self):
|
||||||
|
result = type_to_readable({"anyOf": [{"type": "string"}, {"type": "null"}]})
|
||||||
|
assert result == "str"
|
||||||
|
|
||||||
|
def test_anyof_multiple_types(self):
|
||||||
|
result = type_to_readable({"anyOf": [{"type": "string"}, {"type": "integer"}]})
|
||||||
|
assert result == "str | int"
|
||||||
|
|
||||||
|
def test_enum_type(self):
|
||||||
|
result = type_to_readable(
|
||||||
|
{"type": "string", "enum": ["option1", "option2", "option3"]}
|
||||||
|
)
|
||||||
|
assert result == '"option1" | "option2" | "option3"'
|
||||||
|
|
||||||
|
def test_none_input(self):
|
||||||
|
assert type_to_readable(None) == "Any"
|
||||||
|
|
||||||
|
def test_non_dict_input(self):
|
||||||
|
assert type_to_readable("string") == "string"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractManualContent:
|
||||||
|
"""Tests for extract_manual_content function."""
|
||||||
|
|
||||||
|
def test_extract_how_it_works(self):
|
||||||
|
content = """
|
||||||
|
### How it works
|
||||||
|
<!-- MANUAL: how_it_works -->
|
||||||
|
This is how it works.
|
||||||
|
<!-- END MANUAL -->
|
||||||
|
"""
|
||||||
|
result = extract_manual_content(content)
|
||||||
|
assert result == {"how_it_works": "This is how it works."}
|
||||||
|
|
||||||
|
def test_extract_use_case(self):
|
||||||
|
content = """
|
||||||
|
### Possible use case
|
||||||
|
<!-- MANUAL: use_case -->
|
||||||
|
Example use case here.
|
||||||
|
<!-- END MANUAL -->
|
||||||
|
"""
|
||||||
|
result = extract_manual_content(content)
|
||||||
|
assert result == {"use_case": "Example use case here."}
|
||||||
|
|
||||||
|
def test_extract_multiple_sections(self):
|
||||||
|
content = """
|
||||||
|
<!-- MANUAL: how_it_works -->
|
||||||
|
How it works content.
|
||||||
|
<!-- END MANUAL -->
|
||||||
|
|
||||||
|
<!-- MANUAL: use_case -->
|
||||||
|
Use case content.
|
||||||
|
<!-- END MANUAL -->
|
||||||
|
"""
|
||||||
|
result = extract_manual_content(content)
|
||||||
|
assert result == {
|
||||||
|
"how_it_works": "How it works content.",
|
||||||
|
"use_case": "Use case content.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_empty_content(self):
|
||||||
|
result = extract_manual_content("")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_no_markers(self):
|
||||||
|
result = extract_manual_content("Some content without markers")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateAnchor:
|
||||||
|
"""Tests for generate_anchor function."""
|
||||||
|
|
||||||
|
def test_simple_name(self):
|
||||||
|
assert generate_anchor("Print") == "print"
|
||||||
|
|
||||||
|
def test_multi_word_name(self):
|
||||||
|
assert generate_anchor("Get Weather") == "get-weather"
|
||||||
|
|
||||||
|
def test_name_with_parentheses(self):
|
||||||
|
assert generate_anchor("Something (Optional)") == "something-optional"
|
||||||
|
|
||||||
|
def test_already_lowercase(self):
|
||||||
|
assert generate_anchor("already lowercase") == "already-lowercase"
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegration:
|
||||||
|
"""Integration tests that require block loading."""
|
||||||
|
|
||||||
|
def test_load_blocks(self):
|
||||||
|
"""Test that blocks can be loaded successfully."""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from scripts.generate_block_docs import load_all_blocks_for_docs
|
||||||
|
|
||||||
|
blocks = load_all_blocks_for_docs()
|
||||||
|
assert len(blocks) > 0, "Should load at least one block"
|
||||||
|
|
||||||
|
def test_block_doc_has_required_fields(self):
|
||||||
|
"""Test that extracted block docs have required fields."""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from scripts.generate_block_docs import load_all_blocks_for_docs
|
||||||
|
|
||||||
|
blocks = load_all_blocks_for_docs()
|
||||||
|
block = blocks[0]
|
||||||
|
|
||||||
|
assert hasattr(block, "id")
|
||||||
|
assert hasattr(block, "name")
|
||||||
|
assert hasattr(block, "description")
|
||||||
|
assert hasattr(block, "categories")
|
||||||
|
assert hasattr(block, "inputs")
|
||||||
|
assert hasattr(block, "outputs")
|
||||||
|
|
||||||
|
def test_file_mapping_is_deterministic(self):
|
||||||
|
"""Test that file mapping produces consistent results."""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from scripts.generate_block_docs import (
|
||||||
|
get_block_file_mapping,
|
||||||
|
load_all_blocks_for_docs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load blocks twice and compare mappings
|
||||||
|
blocks1 = load_all_blocks_for_docs()
|
||||||
|
blocks2 = load_all_blocks_for_docs()
|
||||||
|
|
||||||
|
mapping1 = get_block_file_mapping(blocks1)
|
||||||
|
mapping2 = get_block_file_mapping(blocks2)
|
||||||
|
|
||||||
|
# Check same files are generated
|
||||||
|
assert set(mapping1.keys()) == set(mapping2.keys())
|
||||||
|
|
||||||
|
# Check same block counts per file
|
||||||
|
for file_path in mapping1:
|
||||||
|
assert len(mapping1[file_path]) == len(mapping2[file_path])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"forked_from_version": null,
|
"forked_from_version": null,
|
||||||
"has_external_trigger": false,
|
"has_external_trigger": false,
|
||||||
"has_human_in_the_loop": false,
|
"has_human_in_the_loop": false,
|
||||||
|
"has_sensitive_action": false,
|
||||||
"id": "graph-123",
|
"id": "graph-123",
|
||||||
"input_schema": {
|
"input_schema": {
|
||||||
"properties": {},
|
"properties": {},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"forked_from_version": null,
|
"forked_from_version": null,
|
||||||
"has_external_trigger": false,
|
"has_external_trigger": false,
|
||||||
"has_human_in_the_loop": false,
|
"has_human_in_the_loop": false,
|
||||||
|
"has_sensitive_action": false,
|
||||||
"id": "graph-123",
|
"id": "graph-123",
|
||||||
"input_schema": {
|
"input_schema": {
|
||||||
"properties": {},
|
"properties": {},
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
"properties": {}
|
"properties": {}
|
||||||
},
|
},
|
||||||
"has_external_trigger": false,
|
"has_external_trigger": false,
|
||||||
|
"has_human_in_the_loop": false,
|
||||||
|
"has_sensitive_action": false,
|
||||||
"trigger_setup_info": null,
|
"trigger_setup_info": null,
|
||||||
"new_output": false,
|
"new_output": false,
|
||||||
"can_access_graph": true,
|
"can_access_graph": true,
|
||||||
@@ -34,7 +36,8 @@
|
|||||||
"is_favorite": false,
|
"is_favorite": false,
|
||||||
"recommended_schedule_cron": null,
|
"recommended_schedule_cron": null,
|
||||||
"settings": {
|
"settings": {
|
||||||
"human_in_the_loop_safe_mode": null
|
"human_in_the_loop_safe_mode": true,
|
||||||
|
"sensitive_action_safe_mode": false
|
||||||
},
|
},
|
||||||
"marketplace_listing": null
|
"marketplace_listing": null
|
||||||
},
|
},
|
||||||
@@ -65,6 +68,8 @@
|
|||||||
"properties": {}
|
"properties": {}
|
||||||
},
|
},
|
||||||
"has_external_trigger": false,
|
"has_external_trigger": false,
|
||||||
|
"has_human_in_the_loop": false,
|
||||||
|
"has_sensitive_action": false,
|
||||||
"trigger_setup_info": null,
|
"trigger_setup_info": null,
|
||||||
"new_output": false,
|
"new_output": false,
|
||||||
"can_access_graph": false,
|
"can_access_graph": false,
|
||||||
@@ -72,7 +77,8 @@
|
|||||||
"is_favorite": false,
|
"is_favorite": false,
|
||||||
"recommended_schedule_cron": null,
|
"recommended_schedule_cron": null,
|
||||||
"settings": {
|
"settings": {
|
||||||
"human_in_the_loop_safe_mode": null
|
"human_in_the_loop_safe_mode": true,
|
||||||
|
"sensitive_action_safe_mode": false
|
||||||
},
|
},
|
||||||
"marketplace_listing": null
|
"marketplace_listing": null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,4 @@ NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY=
|
|||||||
NEXT_PUBLIC_TURNSTILE=disabled
|
NEXT_PUBLIC_TURNSTILE=disabled
|
||||||
|
|
||||||
# PR previews
|
# PR previews
|
||||||
NEXT_PUBLIC_PREVIEW_STEALING_DEV=
|
NEXT_PUBLIC_PREVIEW_STEALING_DEV=
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ While server components and actions are cool and cutting-edge, they introduce a
|
|||||||
|
|
||||||
- Prefer [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) for server state, colocated near consumers (see [state colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster))
|
- Prefer [React Query](https://tanstack.com/query/latest/docs/framework/react/overview) for server state, colocated near consumers (see [state colocation](https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster))
|
||||||
- Co-locate UI state inside components/hooks; keep global state minimal
|
- Co-locate UI state inside components/hooks; keep global state minimal
|
||||||
|
- Avoid `useMemo` and `useCallback` unless you have a measured performance issue
|
||||||
|
- Do not abuse `useEffect`; prefer state colocation and derive values directly when possible
|
||||||
|
|
||||||
### Styling and components
|
### Styling and components
|
||||||
|
|
||||||
@@ -549,9 +551,48 @@ Files:
|
|||||||
Types:
|
Types:
|
||||||
|
|
||||||
- Prefer `interface` for object shapes
|
- Prefer `interface` for object shapes
|
||||||
- Component props should be `interface Props { ... }`
|
- Component props should be `interface Props { ... }` (not exported)
|
||||||
|
- Only use specific exported names (e.g., `export interface MyComponentProps`) when the interface needs to be used outside the component
|
||||||
|
- Keep type definitions inline with the component - do not create separate `types.ts` files unless types are shared across multiple files
|
||||||
- Use precise types; avoid `any` and unsafe casts
|
- Use precise types; avoid `any` and unsafe casts
|
||||||
|
|
||||||
|
**Props naming examples:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ Good - internal props, not exported
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ title, onClose }: Props) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good - exported when needed externally
|
||||||
|
export interface ModalProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ title, onClose }: ModalProps) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - unnecessarily specific name for internal use
|
||||||
|
interface ModalComponentProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - separate types.ts file for single component
|
||||||
|
// types.ts
|
||||||
|
export interface ModalProps { ... }
|
||||||
|
|
||||||
|
// Modal.tsx
|
||||||
|
import type { ModalProps } from './types';
|
||||||
|
```
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
|
|
||||||
- If more than one parameter is needed, pass a single `Args` object for clarity
|
- If more than one parameter is needed, pass a single `Args` object for clarity
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ export default defineConfig({
|
|||||||
client: "react-query",
|
client: "react-query",
|
||||||
httpClient: "fetch",
|
httpClient: "fetch",
|
||||||
indexFiles: false,
|
indexFiles: false,
|
||||||
|
mock: {
|
||||||
|
type: "msw",
|
||||||
|
baseUrl: "http://localhost:3000/api/proxy",
|
||||||
|
generateEachHttpStatus: true,
|
||||||
|
delay: 0,
|
||||||
|
},
|
||||||
override: {
|
override: {
|
||||||
mutator: {
|
mutator: {
|
||||||
path: "./mutators/custom-mutator.ts",
|
path: "./mutators/custom-mutator.ts",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"types": "tsc --noEmit",
|
"types": "tsc --noEmit",
|
||||||
"test": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test",
|
"test": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test",
|
||||||
"test-ui": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test --ui",
|
"test-ui": "NEXT_PUBLIC_PW_TEST=true next build --turbo && playwright test --ui",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest",
|
||||||
"test:no-build": "playwright test",
|
"test:no-build": "playwright test",
|
||||||
"gentests": "playwright codegen http://localhost:3000",
|
"gentests": "playwright codegen http://localhost:3000",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
@@ -118,6 +120,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "4.1.2",
|
"@chromatic-com/storybook": "4.1.2",
|
||||||
|
"happy-dom": "20.3.4",
|
||||||
"@opentelemetry/instrumentation": "0.209.0",
|
"@opentelemetry/instrumentation": "0.209.0",
|
||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.56.1",
|
||||||
"@storybook/addon-a11y": "9.1.5",
|
"@storybook/addon-a11y": "9.1.5",
|
||||||
@@ -127,6 +130,8 @@
|
|||||||
"@storybook/nextjs": "9.1.5",
|
"@storybook/nextjs": "9.1.5",
|
||||||
"@tanstack/eslint-plugin-query": "5.91.2",
|
"@tanstack/eslint-plugin-query": "5.91.2",
|
||||||
"@tanstack/react-query-devtools": "5.90.2",
|
"@tanstack/react-query-devtools": "5.90.2",
|
||||||
|
"@testing-library/dom": "10.4.1",
|
||||||
|
"@testing-library/react": "16.3.2",
|
||||||
"@types/canvas-confetti": "1.9.0",
|
"@types/canvas-confetti": "1.9.0",
|
||||||
"@types/lodash": "4.17.20",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/negotiator": "0.6.4",
|
"@types/negotiator": "0.6.4",
|
||||||
@@ -135,6 +140,7 @@
|
|||||||
"@types/react-dom": "18.3.5",
|
"@types/react-dom": "18.3.5",
|
||||||
"@types/react-modal": "3.16.3",
|
"@types/react-modal": "3.16.3",
|
||||||
"@types/react-window": "1.8.8",
|
"@types/react-window": "1.8.8",
|
||||||
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"axe-playwright": "2.2.2",
|
"axe-playwright": "2.2.2",
|
||||||
"chromatic": "13.3.3",
|
"chromatic": "13.3.3",
|
||||||
"concurrently": "9.2.1",
|
"concurrently": "9.2.1",
|
||||||
@@ -153,7 +159,9 @@
|
|||||||
"require-in-the-middle": "8.0.1",
|
"require-in-the-middle": "8.0.1",
|
||||||
"storybook": "9.1.5",
|
"storybook": "9.1.5",
|
||||||
"tailwindcss": "3.4.17",
|
"tailwindcss": "3.4.17",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vite-tsconfig-paths": "6.0.4",
|
||||||
|
"vitest": "4.0.17"
|
||||||
},
|
},
|
||||||
"msw": {
|
"msw": {
|
||||||
"workerDirectory": [
|
"workerDirectory": [
|
||||||
|
|||||||
1118
autogpt_platform/frontend/pnpm-lock.yaml
generated
BIN
autogpt_platform/frontend/public/integrations/amazon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
BIN
autogpt_platform/frontend/public/integrations/cohere.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
autogpt_platform/frontend/public/integrations/deepseek.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
autogpt_platform/frontend/public/integrations/gemini.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
autogpt_platform/frontend/public/integrations/gryphe.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
autogpt_platform/frontend/public/integrations/microsoft.webp
Normal file
|
After Width: | Height: | Size: 374 B |
BIN
autogpt_platform/frontend/public/integrations/mistral.png
Normal file
|
After Width: | Height: | Size: 663 B |
BIN
autogpt_platform/frontend/public/integrations/moonshot.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
autogpt_platform/frontend/public/integrations/nousresearch.avif
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
autogpt_platform/frontend/public/integrations/perplexity.webp
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
autogpt_platform/frontend/public/integrations/qwen.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
autogpt_platform/frontend/public/integrations/xai.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
|
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const LOGOUT_REDIRECT_DELAY_MS = 400;
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise(function resolveAfterDelay(resolve) {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogoutPage() {
|
||||||
|
const { logOut } = useSupabase();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const hasStartedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function handleLogoutEffect() {
|
||||||
|
if (hasStartedRef.current) return;
|
||||||
|
hasStartedRef.current = true;
|
||||||
|
|
||||||
|
async function runLogout() {
|
||||||
|
try {
|
||||||
|
await logOut();
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: "Failed to log out. Redirecting to login.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await wait(LOGOUT_REDIRECT_DELAY_MS);
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void runLogout();
|
||||||
|
},
|
||||||
|
[logOut, router, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center px-4">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
<Text variant="body" className="text-center">
|
||||||
|
Logging you out...
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams, origin } = new URL(request.url);
|
const { searchParams, origin } = new URL(request.url);
|
||||||
const code = searchParams.get("code");
|
const code = searchParams.get("code");
|
||||||
|
|
||||||
let next = "/marketplace";
|
let next = "/";
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
const supabase = await getServerSupabase();
|
const supabase = await getServerSupabase();
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||||
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
|
import { CircleNotchIcon, PlayIcon, StopIcon } from "@phosphor-icons/react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||||
import { useRunGraph } from "./useRunGraph";
|
import { useRunGraph } from "./useRunGraph";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
||||||
const {
|
const {
|
||||||
@@ -24,6 +25,31 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
|||||||
useShallow((state) => state.isGraphRunning),
|
useShallow((state) => state.isGraphRunning),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLoading = isExecutingGraph || isTerminatingGraph || isSaving;
|
||||||
|
|
||||||
|
// Determine which icon to show with proper animation
|
||||||
|
const renderIcon = () => {
|
||||||
|
const iconClass = cn(
|
||||||
|
"size-4 transition-transform duration-200 ease-out",
|
||||||
|
!isLoading && "group-hover:scale-110",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<CircleNotchIcon
|
||||||
|
className={cn(iconClass, "animate-spin")}
|
||||||
|
weight="bold"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGraphRunning) {
|
||||||
|
return <StopIcon className={iconClass} weight="fill" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PlayIcon className={iconClass} weight="fill" />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -33,18 +59,18 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
|
|||||||
variant={isGraphRunning ? "destructive" : "primary"}
|
variant={isGraphRunning ? "destructive" : "primary"}
|
||||||
data-id={isGraphRunning ? "stop-graph-button" : "run-graph-button"}
|
data-id={isGraphRunning ? "stop-graph-button" : "run-graph-button"}
|
||||||
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
||||||
disabled={!flowID || isExecutingGraph || isTerminatingGraph}
|
disabled={!flowID || isLoading}
|
||||||
loading={isExecutingGraph || isTerminatingGraph || isSaving}
|
className="group"
|
||||||
>
|
>
|
||||||
{!isGraphRunning ? (
|
{renderIcon()}
|
||||||
<PlayIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<StopIcon className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{isGraphRunning ? "Stop agent" : "Run agent"}
|
{isLoading
|
||||||
|
? "Processing..."
|
||||||
|
: isGraphRunning
|
||||||
|
? "Stop agent"
|
||||||
|
: "Run agent"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<RunInputDialog
|
<RunInputDialog
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useRunInputDialog } from "./useRunInputDialog";
|
|||||||
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
||||||
import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
|
import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
|
||||||
|
|
||||||
export const RunInputDialog = ({
|
export const RunInputDialog = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -23,19 +24,17 @@ export const RunInputDialog = ({
|
|||||||
const hasInputs = useGraphStore((state) => state.hasInputs);
|
const hasInputs = useGraphStore((state) => state.hasInputs);
|
||||||
const hasCredentials = useGraphStore((state) => state.hasCredentials);
|
const hasCredentials = useGraphStore((state) => state.hasCredentials);
|
||||||
const inputSchema = useGraphStore((state) => state.inputSchema);
|
const inputSchema = useGraphStore((state) => state.inputSchema);
|
||||||
const credentialsSchema = useGraphStore(
|
|
||||||
(state) => state.credentialsInputSchema,
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
credentialsUiSchema,
|
credentialFields,
|
||||||
|
requiredCredentials,
|
||||||
handleManualRun,
|
handleManualRun,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
openCronSchedulerDialog,
|
openCronSchedulerDialog,
|
||||||
setOpenCronSchedulerDialog,
|
setOpenCronSchedulerDialog,
|
||||||
inputValues,
|
inputValues,
|
||||||
credentialValues,
|
credentialValues,
|
||||||
handleCredentialChange,
|
handleCredentialFieldChange,
|
||||||
isExecutingGraph,
|
isExecutingGraph,
|
||||||
} = useRunInputDialog({ setIsOpen });
|
} = useRunInputDialog({ setIsOpen });
|
||||||
|
|
||||||
@@ -62,67 +61,67 @@ export const RunInputDialog = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
set: setIsOpen,
|
set: setIsOpen,
|
||||||
}}
|
}}
|
||||||
styling={{ maxWidth: "600px", minWidth: "600px" }}
|
styling={{ maxWidth: "700px", minWidth: "700px" }}
|
||||||
>
|
>
|
||||||
<Dialog.Content>
|
<Dialog.Content>
|
||||||
<div className="space-y-6 p-1" data-id="run-input-dialog-content">
|
<div
|
||||||
{/* Credentials Section */}
|
className="grid grid-cols-[1fr_auto] gap-10 p-1"
|
||||||
{hasCredentials() && (
|
data-id="run-input-dialog-content"
|
||||||
<div data-id="run-input-credentials-section">
|
>
|
||||||
<div className="mb-4">
|
<div className="space-y-6">
|
||||||
<Text variant="h4" className="text-gray-900">
|
{/* Credentials Section */}
|
||||||
Credentials
|
{hasCredentials() && credentialFields.length > 0 && (
|
||||||
</Text>
|
<div data-id="run-input-credentials-section">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Text variant="h4" className="text-gray-900">
|
||||||
|
Credentials
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="px-2" data-id="run-input-credentials-form">
|
||||||
|
<CredentialsGroupedView
|
||||||
|
credentialFields={credentialFields}
|
||||||
|
requiredCredentials={requiredCredentials}
|
||||||
|
inputCredentials={credentialValues}
|
||||||
|
inputValues={inputValues}
|
||||||
|
onCredentialChange={handleCredentialFieldChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2" data-id="run-input-credentials-form">
|
)}
|
||||||
<FormRenderer
|
|
||||||
jsonSchema={credentialsSchema as RJSFSchema}
|
|
||||||
handleChange={(v) => handleCredentialChange(v.formData)}
|
|
||||||
uiSchema={credentialsUiSchema}
|
|
||||||
initialValues={{}}
|
|
||||||
formContext={{
|
|
||||||
showHandles: false,
|
|
||||||
size: "large",
|
|
||||||
showOptionalToggle: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Inputs Section */}
|
{/* Inputs Section */}
|
||||||
{hasInputs() && (
|
{hasInputs() && (
|
||||||
<div data-id="run-input-inputs-section">
|
<div data-id="run-input-inputs-section">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Text variant="h4" className="text-gray-900">
|
<Text variant="h4" className="text-gray-900">
|
||||||
Inputs
|
Inputs
|
||||||
</Text>
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div data-id="run-input-inputs-form">
|
||||||
|
<FormRenderer
|
||||||
|
jsonSchema={inputSchema as RJSFSchema}
|
||||||
|
handleChange={(v) => handleInputChange(v.formData)}
|
||||||
|
uiSchema={uiSchema}
|
||||||
|
initialValues={{}}
|
||||||
|
formContext={{
|
||||||
|
showHandles: false,
|
||||||
|
size: "large",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-id="run-input-inputs-form">
|
)}
|
||||||
<FormRenderer
|
</div>
|
||||||
jsonSchema={inputSchema as RJSFSchema}
|
|
||||||
handleChange={(v) => handleInputChange(v.formData)}
|
|
||||||
uiSchema={uiSchema}
|
|
||||||
initialValues={{}}
|
|
||||||
formContext={{
|
|
||||||
showHandles: false,
|
|
||||||
size: "large",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<div
|
<div
|
||||||
className="flex justify-end pt-2"
|
className="flex flex-col items-end justify-start"
|
||||||
data-id="run-input-actions-section"
|
data-id="run-input-actions-section"
|
||||||
>
|
>
|
||||||
{purpose === "run" && (
|
{purpose === "run" && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
className="group h-fit min-w-0 gap-2"
|
className="group h-fit min-w-0 gap-2 px-10"
|
||||||
onClick={handleManualRun}
|
onClick={handleManualRun}
|
||||||
loading={isExecutingGraph}
|
loading={isExecutingGraph}
|
||||||
data-id="run-input-manual-run-button"
|
data-id="run-input-manual-run-button"
|
||||||
@@ -137,7 +136,7 @@ export const RunInputDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="large"
|
size="large"
|
||||||
className="group h-fit min-w-0 gap-2"
|
className="group h-fit min-w-0 gap-2 px-10"
|
||||||
onClick={() => setOpenCronSchedulerDialog(true)}
|
onClick={() => setOpenCronSchedulerDialog(true)}
|
||||||
data-id="run-input-schedule-button"
|
data-id="run-input-schedule-button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import {
|
|||||||
GraphExecutionMeta,
|
GraphExecutionMeta,
|
||||||
} from "@/lib/autogpt-server-api";
|
} from "@/lib/autogpt-server-api";
|
||||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
|
|
||||||
import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
|
|
||||||
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||||
import { useReactFlow } from "@xyflow/react";
|
import { useReactFlow } from "@xyflow/react";
|
||||||
|
import type { CredentialField } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/helpers";
|
||||||
|
|
||||||
export const useRunInputDialog = ({
|
export const useRunInputDialog = ({
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
@@ -120,27 +119,32 @@ export const useRunInputDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// We are rendering the credentials field differently compared to other fields.
|
// Convert credentials schema to credential fields array for CredentialsGroupedView
|
||||||
// In the node, we have the field name as "credential" - so our library catches it and renders it differently.
|
const credentialFields: CredentialField[] = useMemo(() => {
|
||||||
// But here we have a different name, something like `Firecrawl credentials`, so here we are telling the library that this field is a credential field type.
|
if (!credentialsSchema?.properties) return [];
|
||||||
|
return Object.entries(credentialsSchema.properties);
|
||||||
|
}, [credentialsSchema]);
|
||||||
|
|
||||||
const credentialsUiSchema = useMemo(() => {
|
// Get required credentials as a Set
|
||||||
const dynamicUiSchema: any = { ...uiSchema };
|
const requiredCredentials = useMemo(() => {
|
||||||
|
return new Set<string>(credentialsSchema?.required || []);
|
||||||
|
}, [credentialsSchema]);
|
||||||
|
|
||||||
if (credentialsSchema?.properties) {
|
// Handler for individual credential changes
|
||||||
Object.keys(credentialsSchema.properties).forEach((fieldName) => {
|
const handleCredentialFieldChange = useCallback(
|
||||||
const fieldSchema = credentialsSchema.properties[fieldName];
|
(key: string, value?: CredentialsMetaInput) => {
|
||||||
if (isCredentialFieldSchema(fieldSchema)) {
|
setCredentialValues((prev) => {
|
||||||
dynamicUiSchema[fieldName] = {
|
if (value) {
|
||||||
...dynamicUiSchema[fieldName],
|
return { ...prev, [key]: value };
|
||||||
"ui:field": "custom/credential_field",
|
} else {
|
||||||
};
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
[],
|
||||||
return dynamicUiSchema;
|
);
|
||||||
}, [credentialsSchema]);
|
|
||||||
|
|
||||||
const handleManualRun = async () => {
|
const handleManualRun = async () => {
|
||||||
// Filter out incomplete credentials (those without a valid id)
|
// Filter out incomplete credentials (those without a valid id)
|
||||||
@@ -173,12 +177,14 @@ export const useRunInputDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentialsUiSchema,
|
credentialFields,
|
||||||
|
requiredCredentials,
|
||||||
inputValues,
|
inputValues,
|
||||||
credentialValues,
|
credentialValues,
|
||||||
isExecutingGraph,
|
isExecutingGraph,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleCredentialChange,
|
handleCredentialChange,
|
||||||
|
handleCredentialFieldChange,
|
||||||
handleManualRun,
|
handleManualRun,
|
||||||
openCronSchedulerDialog,
|
openCronSchedulerDialog,
|
||||||
setOpenCronSchedulerDialog,
|
setOpenCronSchedulerDialog,
|
||||||
|
|||||||
@@ -18,69 +18,118 @@ interface Props {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SafeModeButtonProps {
|
||||||
|
isEnabled: boolean;
|
||||||
|
label: string;
|
||||||
|
tooltipEnabled: string;
|
||||||
|
tooltipDisabled: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SafeModeButton({
|
||||||
|
isEnabled,
|
||||||
|
label,
|
||||||
|
tooltipEnabled,
|
||||||
|
tooltipDisabled,
|
||||||
|
onToggle,
|
||||||
|
isPending,
|
||||||
|
fullWidth = false,
|
||||||
|
}: SafeModeButtonProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={100}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={isEnabled ? "primary" : "outline"}
|
||||||
|
size="small"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={isPending}
|
||||||
|
className={cn("justify-start", fullWidth ? "w-full" : "")}
|
||||||
|
>
|
||||||
|
{isEnabled ? (
|
||||||
|
<>
|
||||||
|
<ShieldCheckIcon weight="bold" size={16} />
|
||||||
|
<Text variant="body" className="text-zinc-200">
|
||||||
|
{label}: ON
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldIcon weight="bold" size={16} />
|
||||||
|
<Text variant="body" className="text-zinc-600">
|
||||||
|
{label}: OFF
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-medium">
|
||||||
|
{label}: {isEnabled ? "ON" : "OFF"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{isEnabled ? tooltipEnabled : tooltipDisabled}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FloatingSafeModeToggle({
|
export function FloatingSafeModeToggle({
|
||||||
graph,
|
graph,
|
||||||
className,
|
className,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
currentSafeMode,
|
currentHITLSafeMode,
|
||||||
|
showHITLToggle,
|
||||||
|
isHITLStateUndetermined,
|
||||||
|
handleHITLToggle,
|
||||||
|
currentSensitiveActionSafeMode,
|
||||||
|
showSensitiveActionToggle,
|
||||||
|
handleSensitiveActionToggle,
|
||||||
isPending,
|
isPending,
|
||||||
shouldShowToggle,
|
shouldShowToggle,
|
||||||
isStateUndetermined,
|
|
||||||
handleToggle,
|
|
||||||
} = useAgentSafeMode(graph);
|
} = useAgentSafeMode(graph);
|
||||||
|
|
||||||
if (!shouldShowToggle || isStateUndetermined || isPending) {
|
if (!shouldShowToggle || isPending) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showHITL = showHITLToggle && !isHITLStateUndetermined;
|
||||||
|
const showSensitive = showSensitiveActionToggle;
|
||||||
|
|
||||||
|
if (!showHITL && !showSensitive) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("fixed z-50", className)}>
|
<div className={cn("fixed z-50 flex flex-col gap-2", className)}>
|
||||||
<Tooltip delayDuration={100}>
|
{showHITL && (
|
||||||
<TooltipTrigger asChild>
|
<SafeModeButton
|
||||||
<Button
|
isEnabled={currentHITLSafeMode}
|
||||||
variant={currentSafeMode! ? "primary" : "outline"}
|
label="Human in the loop block approval"
|
||||||
key={graph.id}
|
tooltipEnabled="The agent will pause at human-in-the-loop blocks and wait for your approval"
|
||||||
size="small"
|
tooltipDisabled="Human in the loop blocks will proceed automatically"
|
||||||
title={
|
onToggle={handleHITLToggle}
|
||||||
currentSafeMode!
|
isPending={isPending}
|
||||||
? "Safe Mode: ON. Human in the loop blocks require manual review"
|
fullWidth={fullWidth}
|
||||||
: "Safe Mode: OFF. Human in the loop blocks proceed automatically"
|
/>
|
||||||
}
|
)}
|
||||||
onClick={handleToggle}
|
{showSensitive && (
|
||||||
className={cn(fullWidth ? "w-full" : "")}
|
<SafeModeButton
|
||||||
>
|
isEnabled={currentSensitiveActionSafeMode}
|
||||||
{currentSafeMode! ? (
|
label="Sensitive actions blocks approval"
|
||||||
<>
|
tooltipEnabled="The agent will pause at sensitive action blocks and wait for your approval"
|
||||||
<ShieldCheckIcon weight="bold" size={16} />
|
tooltipDisabled="Sensitive action blocks will proceed automatically"
|
||||||
<Text variant="body" className="text-zinc-200">
|
onToggle={handleSensitiveActionToggle}
|
||||||
Safe Mode: ON
|
isPending={isPending}
|
||||||
</Text>
|
fullWidth={fullWidth}
|
||||||
</>
|
/>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
<ShieldIcon weight="bold" size={16} />
|
|
||||||
<Text variant="body" className="text-zinc-600">
|
|
||||||
Safe Mode: OFF
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium">
|
|
||||||
Safe Mode: {currentSafeMode! ? "ON" : "OFF"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{currentSafeMode!
|
|
||||||
? "Human in the loop blocks require manual review"
|
|
||||||
: "Human in the loop blocks proceed automatically"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ export const CustomControls = memo(
|
|||||||
const controls = [
|
const controls = [
|
||||||
{
|
{
|
||||||
id: "zoom-in-button",
|
id: "zoom-in-button",
|
||||||
icon: <PlusIcon className="size-4" />,
|
icon: <PlusIcon className="size-3.5 text-zinc-600" />,
|
||||||
label: "Zoom In",
|
label: "Zoom In",
|
||||||
onClick: () => zoomIn(),
|
onClick: () => zoomIn(),
|
||||||
className: "h-10 w-10 border-none",
|
className: "h-10 w-10 border-none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "zoom-out-button",
|
id: "zoom-out-button",
|
||||||
icon: <MinusIcon className="size-4" />,
|
icon: <MinusIcon className="size-3.5 text-zinc-600" />,
|
||||||
label: "Zoom Out",
|
label: "Zoom Out",
|
||||||
onClick: () => zoomOut(),
|
onClick: () => zoomOut(),
|
||||||
className: "h-10 w-10 border-none",
|
className: "h-10 w-10 border-none",
|
||||||
@@ -68,9 +68,9 @@ export const CustomControls = memo(
|
|||||||
{
|
{
|
||||||
id: "tutorial-button",
|
id: "tutorial-button",
|
||||||
icon: isTutorialLoading ? (
|
icon: isTutorialLoading ? (
|
||||||
<CircleNotchIcon className="size-4 animate-spin" />
|
<CircleNotchIcon className="size-3.5 animate-spin text-zinc-600" />
|
||||||
) : (
|
) : (
|
||||||
<ChalkboardIcon className="size-4" />
|
<ChalkboardIcon className="size-3.5 text-zinc-600" />
|
||||||
),
|
),
|
||||||
label: isTutorialLoading ? "Loading Tutorial..." : "Start Tutorial",
|
label: isTutorialLoading ? "Loading Tutorial..." : "Start Tutorial",
|
||||||
onClick: handleTutorialClick,
|
onClick: handleTutorialClick,
|
||||||
@@ -79,7 +79,7 @@ export const CustomControls = memo(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fit-view-button",
|
id: "fit-view-button",
|
||||||
icon: <FrameCornersIcon className="size-4" />,
|
icon: <FrameCornersIcon className="size-3.5 text-zinc-600" />,
|
||||||
label: "Fit View",
|
label: "Fit View",
|
||||||
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
|
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
|
||||||
className: "h-10 w-10 border-none",
|
className: "h-10 w-10 border-none",
|
||||||
@@ -87,9 +87,9 @@ export const CustomControls = memo(
|
|||||||
{
|
{
|
||||||
id: "lock-button",
|
id: "lock-button",
|
||||||
icon: !isLocked ? (
|
icon: !isLocked ? (
|
||||||
<LockOpenIcon className="size-4" />
|
<LockOpenIcon className="size-3.5 text-zinc-600" />
|
||||||
) : (
|
) : (
|
||||||
<LockIcon className="size-4" />
|
<LockIcon className="size-3.5 text-zinc-600" />
|
||||||
),
|
),
|
||||||
label: "Toggle Lock",
|
label: "Toggle Lock",
|
||||||
onClick: () => setIsLocked(!isLocked),
|
onClick: () => setIsLocked(!isLocked),
|
||||||
|
|||||||
@@ -139,14 +139,6 @@ export const useFlow = () => {
|
|||||||
useNodeStore.getState().setNodes([]);
|
useNodeStore.getState().setNodes([]);
|
||||||
useNodeStore.getState().clearResolutionState();
|
useNodeStore.getState().clearResolutionState();
|
||||||
addNodes(customNodes);
|
addNodes(customNodes);
|
||||||
|
|
||||||
// Sync hardcoded values with handle IDs.
|
|
||||||
// If a key–value field has a key without a value, the backend omits it from hardcoded values.
|
|
||||||
// But if a handleId exists for that key, it causes inconsistency.
|
|
||||||
// This ensures hardcoded values stay in sync with handle IDs.
|
|
||||||
customNodes.forEach((node) => {
|
|
||||||
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [customNodes, addNodes]);
|
}, [customNodes, addNodes]);
|
||||||
|
|
||||||
@@ -158,6 +150,14 @@ export const useFlow = () => {
|
|||||||
}
|
}
|
||||||
}, [graph?.links, addLinks]);
|
}, [graph?.links, addLinks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (customNodes.length > 0 && graph?.links) {
|
||||||
|
customNodes.forEach((node) => {
|
||||||
|
useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [customNodes, graph?.links]);
|
||||||
|
|
||||||
// update node execution status in nodes
|
// update node execution status in nodes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export type CustomEdgeData = {
|
|||||||
beadUp?: number;
|
beadUp?: number;
|
||||||
beadDown?: number;
|
beadDown?: number;
|
||||||
beadData?: Map<string, NodeExecutionResult["status"]>;
|
beadData?: Map<string, NodeExecutionResult["status"]>;
|
||||||
|
edgeColorClass?: string;
|
||||||
|
edgeHexColor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomEdge = XYEdge<CustomEdgeData, "custom">;
|
export type CustomEdge = XYEdge<CustomEdgeData, "custom">;
|
||||||
@@ -36,7 +38,6 @@ const CustomEdge = ({
|
|||||||
selected,
|
selected,
|
||||||
}: EdgeProps<CustomEdge>) => {
|
}: EdgeProps<CustomEdge>) => {
|
||||||
const removeConnection = useEdgeStore((state) => state.removeEdge);
|
const removeConnection = useEdgeStore((state) => state.removeEdge);
|
||||||
// Subscribe to the brokenEdgeIDs map and check if this edge is broken across any node
|
|
||||||
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
|
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ const CustomEdge = ({
|
|||||||
const isStatic = data?.isStatic ?? false;
|
const isStatic = data?.isStatic ?? false;
|
||||||
const beadUp = data?.beadUp ?? 0;
|
const beadUp = data?.beadUp ?? 0;
|
||||||
const beadDown = data?.beadDown ?? 0;
|
const beadDown = data?.beadDown ?? 0;
|
||||||
|
const edgeColorClass = data?.edgeColorClass;
|
||||||
|
|
||||||
const handleRemoveEdge = () => {
|
const handleRemoveEdge = () => {
|
||||||
removeConnection(id);
|
removeConnection(id);
|
||||||
@@ -70,7 +72,9 @@ const CustomEdge = ({
|
|||||||
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
|
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
|
||||||
: selected
|
: selected
|
||||||
? "stroke-zinc-800"
|
? "stroke-zinc-800"
|
||||||
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
: edgeColorClass
|
||||||
|
? cn(edgeColorClass, "opacity-70 hover:opacity-100")
|
||||||
|
: "stroke-zinc-500/50 hover:stroke-zinc-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<JSBeads
|
<JSBeads
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useCallback } from "react";
|
|||||||
import { useNodeStore } from "../../../stores/nodeStore";
|
import { useNodeStore } from "../../../stores/nodeStore";
|
||||||
import { useHistoryStore } from "../../../stores/historyStore";
|
import { useHistoryStore } from "../../../stores/historyStore";
|
||||||
import { CustomEdge } from "./CustomEdge";
|
import { CustomEdge } from "./CustomEdge";
|
||||||
|
import { getEdgeColorFromOutputType } from "../nodes/helpers";
|
||||||
|
|
||||||
export const useCustomEdge = () => {
|
export const useCustomEdge = () => {
|
||||||
const edges = useEdgeStore((s) => s.edges);
|
const edges = useEdgeStore((s) => s.edges);
|
||||||
@@ -34,8 +35,13 @@ export const useCustomEdge = () => {
|
|||||||
if (exists) return;
|
if (exists) return;
|
||||||
|
|
||||||
const nodes = useNodeStore.getState().nodes;
|
const nodes = useNodeStore.getState().nodes;
|
||||||
const isStatic = nodes.find((n) => n.id === conn.source)?.data
|
const sourceNode = nodes.find((n) => n.id === conn.source);
|
||||||
?.staticOutput;
|
const isStatic = sourceNode?.data?.staticOutput;
|
||||||
|
|
||||||
|
const { colorClass, hexColor } = getEdgeColorFromOutputType(
|
||||||
|
sourceNode?.data?.outputSchema,
|
||||||
|
conn.sourceHandle,
|
||||||
|
);
|
||||||
|
|
||||||
addEdge({
|
addEdge({
|
||||||
source: conn.source,
|
source: conn.source,
|
||||||
@@ -44,6 +50,8 @@ export const useCustomEdge = () => {
|
|||||||
targetHandle: conn.targetHandle,
|
targetHandle: conn.targetHandle,
|
||||||
data: {
|
data: {
|
||||||
isStatic,
|
isStatic,
|
||||||
|
edgeColorClass: colorClass,
|
||||||
|
edgeHexColor: hexColor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { Button } from "@/components/atoms/Button/Button";
|
import { Button } from "@/components/atoms/Button/Button";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/molecules/Accordion/Accordion";
|
||||||
import { beautifyString, cn } from "@/lib/utils";
|
import { beautifyString, cn } from "@/lib/utils";
|
||||||
import { CaretDownIcon, CopyIcon, CheckIcon } from "@phosphor-icons/react";
|
import { CopyIcon, CheckIcon } from "@phosphor-icons/react";
|
||||||
import { NodeDataViewer } from "./components/NodeDataViewer/NodeDataViewer";
|
import { NodeDataViewer } from "./components/NodeDataViewer/NodeDataViewer";
|
||||||
import { ContentRenderer } from "./components/ContentRenderer";
|
import { ContentRenderer } from "./components/ContentRenderer";
|
||||||
import { useNodeOutput } from "./useNodeOutput";
|
import { useNodeOutput } from "./useNodeOutput";
|
||||||
import { ViewMoreData } from "./components/ViewMoreData";
|
import { ViewMoreData } from "./components/ViewMoreData";
|
||||||
|
|
||||||
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
||||||
const {
|
const { outputData, copiedKey, handleCopy, executionResultId, inputData } =
|
||||||
outputData,
|
useNodeOutput(nodeId);
|
||||||
isExpanded,
|
|
||||||
setIsExpanded,
|
|
||||||
copiedKey,
|
|
||||||
handleCopy,
|
|
||||||
executionResultId,
|
|
||||||
inputData,
|
|
||||||
} = useNodeOutput(nodeId);
|
|
||||||
|
|
||||||
if (Object.keys(outputData).length === 0) {
|
if (Object.keys(outputData).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -25,122 +24,117 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tutorial-id={`node-output`}
|
data-tutorial-id={`node-output`}
|
||||||
className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4"
|
className="rounded-b-xl border-t border-zinc-200 px-4 py-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<Accordion type="single" collapsible defaultValue="node-output">
|
||||||
<Text variant="body-medium" className="!font-semibold text-slate-700">
|
<AccordionItem value="node-output" className="border-none">
|
||||||
Node Output
|
<AccordionTrigger className="py-2 hover:no-underline">
|
||||||
</Text>
|
<Text
|
||||||
<Button
|
variant="body-medium"
|
||||||
variant="ghost"
|
className="!font-semibold text-slate-700"
|
||||||
size="small"
|
>
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
Node Output
|
||||||
className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
|
</Text>
|
||||||
>
|
</AccordionTrigger>
|
||||||
<CaretDownIcon
|
<AccordionContent className="pt-2">
|
||||||
size={16}
|
<div className="flex max-w-[350px] flex-col gap-4">
|
||||||
weight="bold"
|
<div className="space-y-2">
|
||||||
className={`transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
<Text variant="small-medium">Input</Text>
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
<ContentRenderer value={inputData} shortContent={false} />
|
||||||
<>
|
|
||||||
<div className="flex max-w-[350px] flex-col gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Text variant="small-medium">Input</Text>
|
|
||||||
|
|
||||||
<ContentRenderer value={inputData} shortContent={false} />
|
<div className="mt-1 flex justify-end gap-1">
|
||||||
|
<NodeDataViewer
|
||||||
<div className="mt-1 flex justify-end gap-1">
|
data={inputData}
|
||||||
<NodeDataViewer
|
pinName="Input"
|
||||||
data={inputData}
|
execId={executionResultId}
|
||||||
pinName="Input"
|
/>
|
||||||
execId={executionResultId}
|
<Button
|
||||||
/>
|
variant="secondary"
|
||||||
<Button
|
size="small"
|
||||||
variant="secondary"
|
onClick={() => handleCopy("input", inputData)}
|
||||||
size="small"
|
className={cn(
|
||||||
onClick={() => handleCopy("input", inputData)}
|
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
||||||
className={cn(
|
copiedKey === "input" &&
|
||||||
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
||||||
copiedKey === "input" &&
|
)}
|
||||||
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
>
|
||||||
)}
|
{copiedKey === "input" ? (
|
||||||
>
|
<CheckIcon size={12} className="text-green-600" />
|
||||||
{copiedKey === "input" ? (
|
) : (
|
||||||
<CheckIcon size={12} className="text-green-600" />
|
<CopyIcon size={12} />
|
||||||
) : (
|
)}
|
||||||
<CopyIcon size={12} />
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{Object.entries(outputData)
|
{Object.entries(outputData)
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map(([key, value]) => (
|
.map(([key, value]) => (
|
||||||
<div key={key} className="flex flex-col gap-2">
|
<div key={key} className="flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Text
|
<Text
|
||||||
variant="small-medium"
|
variant="small-medium"
|
||||||
className="!font-semibold text-slate-600"
|
className="!font-semibold text-slate-600"
|
||||||
>
|
>
|
||||||
Pin:
|
Pin:
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="small" className="text-slate-700">
|
<Text variant="small" className="text-slate-700">
|
||||||
{beautifyString(key)}
|
{beautifyString(key)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
<Text
|
<Text
|
||||||
variant="small"
|
variant="small"
|
||||||
className="!font-semibold text-slate-600"
|
className="!font-semibold text-slate-600"
|
||||||
>
|
>
|
||||||
Data:
|
Data:
|
||||||
</Text>
|
</Text>
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
{value.map((item, index) => (
|
{value.map((item, index) => (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<ContentRenderer value={item} shortContent={true} />
|
<ContentRenderer value={item} shortContent={true} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-1 flex justify-end gap-1">
|
||||||
|
<NodeDataViewer
|
||||||
|
data={value}
|
||||||
|
pinName={key}
|
||||||
|
execId={executionResultId}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleCopy(key, value)}
|
||||||
|
className={cn(
|
||||||
|
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
||||||
|
copiedKey === key &&
|
||||||
|
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copiedKey === key ? (
|
||||||
|
<CheckIcon size={12} className="text-green-600" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon size={12} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mt-1 flex justify-end gap-1">
|
|
||||||
<NodeDataViewer
|
|
||||||
data={value}
|
|
||||||
pinName={key}
|
|
||||||
execId={executionResultId}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleCopy(key, value)}
|
|
||||||
className={cn(
|
|
||||||
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
|
|
||||||
copiedKey === key &&
|
|
||||||
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{copiedKey === key ? (
|
|
||||||
<CheckIcon size={12} className="text-green-600" />
|
|
||||||
) : (
|
|
||||||
<CopyIcon size={12} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{Object.keys(outputData).length > 2 && (
|
{Object.keys(outputData).length > 2 && (
|
||||||
<ViewMoreData outputData={outputData} execId={executionResultId} />
|
<ViewMoreData
|
||||||
)}
|
outputData={outputData}
|
||||||
</>
|
execId={executionResultId}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useShallow } from "zustand/react/shallow";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const useNodeOutput = (nodeId: string) => {
|
export const useNodeOutput = (nodeId: string) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
|
||||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -37,13 +36,10 @@ export const useNodeOutput = (nodeId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
outputData: outputData,
|
outputData,
|
||||||
inputData: inputData,
|
inputData,
|
||||||
isExpanded: isExpanded,
|
copiedKey,
|
||||||
setIsExpanded: setIsExpanded,
|
handleCopy,
|
||||||
copiedKey: copiedKey,
|
|
||||||
setCopiedKey: setCopiedKey,
|
|
||||||
handleCopy: handleCopy,
|
|
||||||
executionResultId: nodeExecutionResult?.node_exec_id,
|
executionResultId: nodeExecutionResult?.node_exec_id,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -187,3 +187,38 @@ export const getTypeDisplayInfo = (schema: any) => {
|
|||||||
hexColor,
|
hexColor,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getEdgeColorFromOutputType(
|
||||||
|
outputSchema: RJSFSchema | undefined,
|
||||||
|
sourceHandle: string,
|
||||||
|
): { colorClass: string; hexColor: string } {
|
||||||
|
const defaultColor = {
|
||||||
|
colorClass: "stroke-zinc-500/50",
|
||||||
|
hexColor: "#6b7280",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!outputSchema?.properties) return defaultColor;
|
||||||
|
|
||||||
|
const properties = outputSchema.properties as Record<string, unknown>;
|
||||||
|
const handleParts = sourceHandle.split("_#_");
|
||||||
|
let currentSchema: Record<string, unknown> = properties;
|
||||||
|
|
||||||
|
for (let i = 0; i < handleParts.length; i++) {
|
||||||
|
const part = handleParts[i];
|
||||||
|
const fieldSchema = currentSchema[part] as Record<string, unknown>;
|
||||||
|
if (!fieldSchema) return defaultColor;
|
||||||
|
|
||||||
|
if (i === handleParts.length - 1) {
|
||||||
|
const { hexColor, colorClass } = getTypeDisplayInfo(fieldSchema);
|
||||||
|
return { colorClass: colorClass.replace("!text-", "stroke-"), hexColor };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldSchema.properties) {
|
||||||
|
currentSchema = fieldSchema.properties as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
return defaultColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultColor;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
// These are SVG Phosphor icons
|
type IconOptions = {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SIZE = 16;
|
||||||
|
const DEFAULT_COLOR = "#52525b"; // zinc-600
|
||||||
|
|
||||||
|
const iconPaths = {
|
||||||
|
ClickIcon: `M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z`,
|
||||||
|
Keyboard: `M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z`,
|
||||||
|
Drag: `M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createIcon(path: string, options: IconOptions = {}): string {
|
||||||
|
const size = options.size ?? DEFAULT_SIZE;
|
||||||
|
const color = options.color ?? DEFAULT_COLOR;
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" fill="${color}" viewBox="0 0 256 256"><path d="${path}"></path></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
export const ICONS = {
|
export const ICONS = {
|
||||||
ClickIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z"></path></svg>`,
|
ClickIcon: createIcon(iconPaths.ClickIcon),
|
||||||
Keyboard: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z"></path></svg>`,
|
Keyboard: createIcon(iconPaths.Keyboard),
|
||||||
Drag: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z"></path></svg>`,
|
Drag: createIcon(iconPaths.Drag),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getIcon(
|
||||||
|
name: keyof typeof iconPaths,
|
||||||
|
options?: IconOptions,
|
||||||
|
): string {
|
||||||
|
return createIcon(iconPaths[name], options);
|
||||||
|
}
|
||||||
|
|||||||