vaha-m commited on
Commit
6fe7c36
Β·
verified Β·
1 Parent(s): 8f0b255

Uploaded from local

Browse files
.dockerignore ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ venv/
8
+ env/
9
+ ENV/
10
+ *.egg-info/
11
+ .pytest_cache/
12
+ .coverage
13
+ htmlcov/
14
+
15
+ # Environment and secrets
16
+ .env
17
+ .env.*
18
+ *.pem
19
+ *.key
20
+ credentials.json
21
+ secrets.toml
22
+
23
+ # Git
24
+ .git/
25
+ .gitignore
26
+ .github/
27
+
28
+ # IDEs
29
+ .vscode/
30
+ .idea/
31
+ *.swp
32
+ *.swo
33
+
34
+ # OS
35
+ .DS_Store
36
+ Thumbs.db
37
+
38
+ # Documentation
39
+ README.md
40
+ *.md
41
+ docs/
42
+
43
+ # Docker
44
+ Dockerfile*
45
+ docker-compose*.yml
46
+ .dockerignore
47
+
48
+ # Jupyter
49
+ *.ipynb
50
+ .ipynb_checkpoints/
51
+
52
+ # Old files
53
+ OLD/
54
+
55
+ # Logs
56
+ *.log
57
+
58
+ # Database
59
+ *.db
60
+ *.sqlite
61
+ *.sqlite3
62
+
63
+ # Temporary files
64
+ tmp/
65
+ temp/
66
+ *.tmp
.env.example ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # HUGGING FACE SPACES - ENVIRONMENT VARIABLES TEMPLATE
3
+ # =============================================================================
4
+ # Copy these variables to your Hugging Face Space Settings > Variables and Secrets
5
+ # DO NOT commit actual API keys to version control
6
+ # =============================================================================
7
+
8
+ # =============================================================================
9
+ # REQUIRED: Core API Keys (Must be configured)
10
+ # =============================================================================
11
+
12
+ # Google Gemini API Key
13
+ # Get from: https://makersuite.google.com/app/apikey
14
+ GOOGLE_API_KEY=your_google_gemini_api_key_here
15
+
16
+ # ChromaDB Cloud API Key (for RAG document storage)
17
+ # Get from: https://www.trychroma.com/
18
+ CHROMA_API_KEY=your_chroma_api_key_here
19
+ CHROMA_TENANT=your_tenant_id
20
+ CHROMA_DATABASE=your_database_name
21
+
22
+ # Alpha Vantage API Key (for stock market data)
23
+ # Get from: https://www.alphavantage.co/support/#api-key
24
+ ALPHA_VANTAGE_API_KEY=your_alpha_vantage_api_key_here
25
+
26
+ # =============================================================================
27
+ # OPTIONAL: Enhanced Features
28
+ # =============================================================================
29
+
30
+ # CoinGecko Pro API Key (for enhanced crypto data)
31
+ # Get from: https://www.coingecko.com/en/api/pricing
32
+ # Note: Free tier available without API key (rate limited)
33
+ COINGECKO_API_KEY=your_coingecko_api_key_here
34
+
35
+ # =============================================================================
36
+ # OPTIONAL: Google Cloud SQL (for Portfolio Tracking)
37
+ # =============================================================================
38
+ # Only needed if you want persistent portfolio tracking with Cloud SQL
39
+ # Otherwise, portfolio data is ephemeral within the session
40
+
41
+ # Google Cloud Project Configuration
42
+ GCP_PROJECT_ID=your_gcp_project_id
43
+ CLOUD_SQL_REGION=us-central1
44
+ CLOUD_SQL_INSTANCE=your_instance_name
45
+
46
+ # Cloud SQL Database Credentials
47
+ CLOUD_SQL_INSTANCE_CONNECTION_NAME=project:region:instance
48
+ CLOUD_SQL_DB_NAME=finance_tracker
49
+ CLOUD_SQL_DB_USER=your_db_user
50
+ CLOUD_SQL_DB_PASS=your_db_password
51
+
52
+ # MCP Toolbox Server URL (for Cloud SQL access)
53
+ # If running MCP Toolbox as separate service/container
54
+ MCP_TOOLBOX_SERVER_URL=http://localhost:5000
55
+
56
+ # =============================================================================
57
+ # OPTIONAL: ChromaDB Configuration
58
+ # =============================================================================
59
+
60
+ # ChromaDB Cloud Host
61
+ CHROMA_CLOUD_HOST=api.trychroma.com
62
+
63
+ # ChromaDB Collection Name
64
+ DOCUMENTS_COLLECTION=mcp-test
65
+
66
+ # Embedding Function (default, openai, cohere, jina, voyageai)
67
+ CHROMA_EMBEDDING_FUNCTION=default
68
+
69
+ # =============================================================================
70
+ # OPTIONAL: UI Configuration
71
+ # =============================================================================
72
+
73
+ # Maximum file upload size (in MB)
74
+ MAX_FILE_SIZE_MB=50
.github/workflows/aws.yaml ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI/CD to ECS Fargate
2
+ on:
3
+ workflow_run:
4
+ workflows: ["Run Unit Tests"]
5
+ types:
6
+ - completed
7
+ env:
8
+ AWS_REGION: eu-west-3
9
+ ECR_REPOSITORY: multi-agent-app
10
+ ECR_REPOSITORY_TOOLBOX: mcp-toolbox
11
+ ECS_SERVICE: multi-agent-service
12
+ ECS_CLUSTER: multi-agent-cluster
13
+ ECS_TASK_DEFINITION: .github/workflows/task_definition.json
14
+ CONTAINER_NAME: multi-agent-app
15
+
16
+ permissions:
17
+ id-token: write
18
+ contents: read
19
+
20
+ jobs:
21
+ check-status:
22
+ runs-on: ubuntu-latest
23
+ if: ${{github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'}}
24
+ steps:
25
+ - name: CI passed on main branch
26
+ run: echo "CI passed on main branch, proceeding to build and deploy."
27
+
28
+ # -------------------------------
29
+ # 1. Build & Push Docker Images
30
+ # -------------------------------
31
+ build-and-push:
32
+ name: Build & Push Docker Images
33
+ needs: [check-status]
34
+ runs-on: ubuntu-latest
35
+ outputs:
36
+ image: ${{ steps.build-image.outputs.image }}
37
+ toolbox-image: ${{ steps.build-toolbox.outputs.image }}
38
+ steps:
39
+ - name: Checkout Repo
40
+ uses: actions/checkout@v4
41
+
42
+ - name: Configure AWS Credentials
43
+ uses: aws-actions/configure-aws-credentials@v4
44
+ with:
45
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
46
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
47
+ aws-region: ${{ env.AWS_REGION }}
48
+
49
+ - name: Login to Amazon ECR
50
+ id: login-ecr
51
+ uses: aws-actions/amazon-ecr-login@v2
52
+
53
+ - name: Build and Push Main App Image
54
+ id: build-image
55
+ env:
56
+ ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
57
+ IMAGE_TAG: ${{ github.sha }}
58
+ run: |
59
+ IMAGE_URI=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
60
+ echo "IMAGE_URI=$IMAGE_URI" >> $GITHUB_ENV
61
+ docker build -f docker/Dockerfile -t $IMAGE_URI .
62
+ docker push $IMAGE_URI
63
+ echo "image=$IMAGE_URI" >> $GITHUB_OUTPUT
64
+
65
+ - name: Build and Push MCP Toolbox Image
66
+ id: build-toolbox
67
+ env:
68
+ ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
69
+ IMAGE_TAG: ${{ github.sha }}
70
+ run: |
71
+ TOOLBOX_IMAGE_URI=$ECR_REGISTRY/$ECR_REPOSITORY_TOOLBOX:$IMAGE_TAG
72
+ docker build -f docker/Dockerfile.toolbox -t $TOOLBOX_IMAGE_URI .
73
+ docker push $TOOLBOX_IMAGE_URI
74
+
75
+ # Also tag and push as 'latest'
76
+ TOOLBOX_LATEST=$ECR_REGISTRY/$ECR_REPOSITORY_TOOLBOX:latest
77
+ docker tag $TOOLBOX_IMAGE_URI $TOOLBOX_LATEST
78
+ docker push $TOOLBOX_LATEST
79
+
80
+ echo "image=$TOOLBOX_IMAGE_URI" >> $GITHUB_OUTPUT
81
+
82
+ # -------------------------------
83
+ # 2. Deploy to ECS
84
+ # -------------------------------
85
+ deploy:
86
+ name: Deploy to ECS Fargate
87
+ needs: build-and-push
88
+ runs-on: ubuntu-latest
89
+ steps:
90
+ - name: Checkout Repo
91
+ uses: actions/checkout@v4
92
+
93
+ - name: Configure AWS Credentials
94
+ uses: aws-actions/configure-aws-credentials@v1
95
+ with:
96
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
97
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
98
+ aws-region: ${{ env.AWS_REGION }}
99
+
100
+ - name: Render Main App Container in Task Definition
101
+ id: render-app
102
+ uses: aws-actions/amazon-ecs-render-task-definition@v1
103
+ with:
104
+ task-definition: ${{ env.ECS_TASK_DEFINITION }}
105
+ container-name: ${{ env.CONTAINER_NAME }}
106
+ image: ${{ needs.build-and-push.outputs.image }}
107
+
108
+ - name: Render MCP Toolbox Container in Task Definition
109
+ id: render-toolbox
110
+ uses: aws-actions/amazon-ecs-render-task-definition@v1
111
+ with:
112
+ task-definition: ${{ steps.render-app.outputs.task-definition }}
113
+ container-name: mcp-toolbox
114
+ image: ${{ needs.build-and-push.outputs.toolbox-image }}
115
+
116
+ - name: Print Final Rendered Task Definition
117
+ run: cat ${{ steps.render-toolbox.outputs.task-definition }}
118
+
119
+ - name: Deploy to ECS
120
+ uses: aws-actions/amazon-ecs-deploy-task-definition@v1
121
+ with:
122
+ task-definition: ${{ steps.render-toolbox.outputs.task-definition }}
123
+ service: ${{ env.ECS_SERVICE }}
124
+ cluster: ${{ env.ECS_CLUSTER }}
125
+ wait-for-service-stability: true
126
+
127
+ - name: Done!
128
+ run: echo "Deployed to ECS Fargate Successfully with both containers!"
.github/workflows/ci.yaml ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Run Unit Tests
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - name: Checkout repo
10
+ uses: actions/checkout@v3
11
+
12
+ - name: Set up Python
13
+ uses: actions/setup-python@v4
14
+ with:
15
+ python-version: '3.11.5'
16
+
17
+ - name: Install dependencies
18
+ run: |
19
+ python -m pip install --upgrade pip
20
+ pip install -r requirements.txt
21
+ pip install pytest
22
+
23
+ - name: Setup Google Cloud credentials
24
+ env:
25
+ GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
26
+ GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
27
+ run: |
28
+
29
+ # If GOOGLE_API_KEY is provided
30
+ if [ ! -z "$GOOGLE_API_KEY" ]; then
31
+ echo "GOOGLE_API_KEY is configured"
32
+ echo "GOOGLE_API_KEY=$GOOGLE_API_KEY" >> $GITHUB_ENV
33
+ fi
34
+
35
+ - name: Setup other environment variables
36
+ env:
37
+ CHROMA_API_KEY: ${{ secrets.CHROMA_API_KEY }}
38
+ CHROMA_TENANT: ${{ secrets.CHROMA_TENANT }}
39
+ CHROMA_DATABASE: ${{ secrets.CHROMA_DATABASE }}
40
+ COINGECKO_API_KEY: ${{ secrets.COINGECKO_API_KEY }}
41
+ ALPHA_VANTAGE_API_KEY: ${{ secrets.ALPHA_VANTAGE_API_KEY }}
42
+ run: |
43
+ # Set ChromaDB credentials if available
44
+ if [ ! -z "$CHROMA_API_KEY" ]; then
45
+ echo "CHROMA_API_KEY=$CHROMA_API_KEY" >> $GITHUB_ENV
46
+ fi
47
+ if [ ! -z "$CHROMA_TENANT" ]; then
48
+ echo "CHROMA_TENANT=$CHROMA_TENANT" >> $GITHUB_ENV
49
+ fi
50
+ if [ ! -z "$CHROMA_DATABASE" ]; then
51
+ echo "CHROMA_DATABASE=$CHROMA_DATABASE" >> $GITHUB_ENV
52
+ fi
53
+
54
+ # Set other API keys if available
55
+ if [ ! -z "$COINGECKO_API_KEY" ]; then
56
+ echo "COINGECKO_API_KEY=$COINGECKO_API_KEY" >> $GITHUB_ENV
57
+ fi
58
+ if [ ! -z "$ALPHA_VANTAGE_API_KEY" ]; then
59
+ echo "ALPHA_VANTAGE_API_KEY=$ALPHA_VANTAGE_API_KEY" >> $GITHUB_ENV
60
+ fi
61
+
62
+ - name: Run unit tests
63
+ run: |
64
+ pytest tests/
.github/workflows/task_definition.json ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "family": "multi-agent-app",
3
+ "networkMode": "awsvpc",
4
+ "requiresCompatibilities": ["FARGATE"],
5
+ "cpu": "1024",
6
+ "memory": "2048",
7
+ "executionRoleArn": "arn:aws:iam::730335436231:role/ecsTaskExecutionRole",
8
+ "taskRoleArn": "arn:aws:iam::730335436231:role/ecsTaskRole",
9
+ "containerDefinitions": [
10
+ {
11
+ "name": "multi-agent-app",
12
+ "image": "730335436231.dkr.ecr.eu-west-3.amazonaws.com/multi-agent-app",
13
+ "essential": true,
14
+ "portMappings": [
15
+ {
16
+ "containerPort": 8501,
17
+ "protocol": "tcp",
18
+ "name": "streamlit"
19
+ },
20
+ {
21
+ "containerPort": 8000,
22
+ "protocol": "tcp",
23
+ "name": "fastapi"
24
+ }
25
+ ],
26
+ "environment": [
27
+ {
28
+ "name": "ENV",
29
+ "value": "production"
30
+ },
31
+ {
32
+ "name": "API_BASE_URL",
33
+ "value": "http://localhost:8000"
34
+ },
35
+ {
36
+ "name": "MCP_TOOLBOX_SERVER_URL",
37
+ "value": "http://localhost:5000"
38
+ }
39
+ ],
40
+ "secrets": [
41
+ {
42
+ "name": "GOOGLE_API_KEY",
43
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:GOOGLE_API_KEY::"
44
+ },
45
+ {
46
+ "name": "ALPHA_VANTAGE_API_KEY",
47
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:ALPHA_VANTAGE_API_KEY::"
48
+ },
49
+ {
50
+ "name": "COINGECKO_API_KEY",
51
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:COINGECKO_API_KEY::"
52
+ },
53
+ {
54
+ "name": "CHROMA_API_KEY",
55
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CHROMA_API_KEY::"
56
+ },
57
+ {
58
+ "name": "CHROMA_TENANT",
59
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CHROMA_TENANT::"
60
+ },
61
+ {
62
+ "name": "CHROMA_DATABASE",
63
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CHROMA_DATABASE::"
64
+ },
65
+ {
66
+ "name": "GCP_PROJECT_ID",
67
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:GCP_PROJECT_ID::"
68
+ },
69
+ {
70
+ "name": "CLOUD_SQL_REGION",
71
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_REGION::"
72
+ },
73
+ {
74
+ "name": "CLOUD_SQL_INSTANCE",
75
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_INSTANCE::"
76
+ },
77
+ {
78
+ "name": "CLOUD_SQL_DB_NAME",
79
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_NAME::"
80
+ },
81
+ {
82
+ "name": "CLOUD_SQL_DB_USER",
83
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_USER::"
84
+ },
85
+ {
86
+ "name": "CLOUD_SQL_DB_PASS",
87
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_PASS::"
88
+ }
89
+ ],
90
+ "dependsOn": [
91
+ {
92
+ "containerName": "mcp-toolbox",
93
+ "condition": "START"
94
+ }
95
+ ],
96
+ "logConfiguration": {
97
+ "logDriver": "awslogs",
98
+ "options": {
99
+ "awslogs-group": "/ecs/multi-agent-app",
100
+ "awslogs-region": "eu-west-3",
101
+ "awslogs-stream-prefix": "ecs"
102
+ }
103
+ }
104
+ },
105
+ {
106
+ "name": "mcp-toolbox",
107
+ "image": "730335436231.dkr.ecr.eu-west-3.amazonaws.com/mcp-toolbox",
108
+ "essential": false,
109
+ "portMappings": [
110
+ {
111
+ "containerPort": 5000,
112
+ "protocol": "tcp",
113
+ "name": "mcp-toolbox"
114
+ }
115
+ ],
116
+ "secrets": [
117
+ {
118
+ "name": "GCP_SERVICE_ACCOUNT_JSON",
119
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:GCP_SERVICE_ACCOUNT_JSON::"
120
+ },
121
+ {
122
+ "name": "CLOUD_SQL_POSTGRES_PROJECT",
123
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:GCP_PROJECT_ID::"
124
+ },
125
+ {
126
+ "name": "CLOUD_SQL_POSTGRES_REGION",
127
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_REGION::"
128
+ },
129
+ {
130
+ "name": "CLOUD_SQL_POSTGRES_INSTANCE",
131
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_INSTANCE::"
132
+ },
133
+ {
134
+ "name": "CLOUD_SQL_POSTGRES_DATABASE",
135
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_NAME::"
136
+ },
137
+ {
138
+ "name": "CLOUD_SQL_POSTGRES_USER",
139
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_USER::"
140
+ },
141
+ {
142
+ "name": "CLOUD_SQL_POSTGRES_PASSWORD",
143
+ "valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_PASS::"
144
+ }
145
+ ],
146
+ "logConfiguration": {
147
+ "logDriver": "awslogs",
148
+ "options": {
149
+ "awslogs-group": "/ecs/mcp-toolbox",
150
+ "awslogs-region": "eu-west-3",
151
+ "awslogs-stream-prefix": "ecs"
152
+ }
153
+ }
154
+ }
155
+ ]
156
+ }
.gitignore ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables
2
+ .env
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+
26
+ # Virtual environments
27
+ venv/
28
+ env/
29
+ ENV/
30
+ env.bak/
31
+ venv.bak/
32
+
33
+ # IDEs
34
+ .vscode/
35
+ .idea/
36
+ *.swp
37
+ *.swo
38
+ *~
39
+
40
+ # OS
41
+ .DS_Store
42
+ Thumbs.db
43
+
44
+ # Gradio
45
+ gradio_queue.db
46
+
47
+ # Streamlit
48
+ .streamlit/secrets.toml
49
+
50
+ # Logs
51
+ *.log
52
+
53
+ # Testing
54
+ .pytest_cache/
55
+ .coverage
56
+ htmlcov/
57
+
58
+ # Jupyter Notebook
59
+ .ipynb_checkpoints
.streamlit/config.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ base="dark"
3
+ primaryColor="#FF4B4B"
4
+ backgroundColor="#0E1117"
5
+ secondaryBackgroundColor="#262730"
6
+ textColor="#FAFAFA"
7
+
8
+ [server]
9
+ headless = true
10
+ port = 8501
11
+ address = "0.0.0.0"
12
+ enableCORS = false
13
+ enableXsrfProtection = true
Dockerfile ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Hugging Face Spaces - Gradio Multi-Agent Assistant
2
+ # This deploys the complete multi-agent system with Gradio UI
3
+
4
+ FROM python:3.11-slim
5
+
6
+ # Install system dependencies including Node.js for MCP servers
7
+ RUN apt-get update && apt-get install -y \
8
+ ca-certificates \
9
+ curl \
10
+ git \
11
+ build-essential \
12
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
13
+ && apt-get install -y nodejs \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Verify installations
17
+ RUN python --version && node --version && npm --version
18
+
19
+ # Set working directory
20
+ WORKDIR /app
21
+
22
+ # Copy requirements first (for layer caching)
23
+ COPY requirements.txt .
24
+
25
+ # Install Python dependencies
26
+ RUN pip install --no-cache-dir --upgrade pip && \
27
+ pip install --no-cache-dir -r requirements.txt
28
+
29
+ # Install npx-based MCP servers globally
30
+ # CoinGecko MCP server for crypto data
31
+ RUN npm install -g @coingecko/coingecko-mcp
32
+
33
+ # Install uvx for Python-based MCP servers
34
+ RUN pip install --no-cache-dir uv
35
+
36
+ # Copy application code
37
+ COPY src/ ./src/
38
+ COPY ui/ ./ui/
39
+
40
+ # Create directory for temporary file uploads
41
+ RUN mkdir -p /tmp/uploads && chmod 777 /tmp/uploads
42
+
43
+ # Copy entrypoint script
44
+ COPY docker/entrypoint-hf.sh /app/entrypoint.sh
45
+ RUN chmod +x /app/entrypoint.sh
46
+
47
+ # Set environment variables for Hugging Face Spaces
48
+ ENV GRADIO_SERVER_NAME="0.0.0.0" \
49
+ GRADIO_SERVER_PORT="7860" \
50
+ PYTHONUNBUFFERED=1
51
+
52
+ # Expose Gradio port (HF Spaces expects 7860)
53
+ EXPOSE 7860
54
+
55
+ # Health check
56
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
57
+ CMD curl -f http://localhost:7860/ || exit 1
58
+
59
+ # Run Gradio app via entrypoint
60
+ CMD ["/app/entrypoint.sh"]
OLD/decision_maker.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decision Maker to route queries to appropriate agents."""
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from config import config
5
+ from langchain_google_genai import ChatGoogleGenerativeAI
6
+
7
+
8
+ class DecisionMaker:
9
+ """LLM-based decision maker to route queries to appropriate agents."""
10
+
11
+ def __init__(self):
12
+ self.name = "Decision Maker"
13
+ self.model = ChatGoogleGenerativeAI(
14
+ model="gemini-2.5-pro",
15
+ temperature=0.1,
16
+ google_api_key=config.GOOGLE_API_KEY,
17
+ )
18
+
19
+ self.agent_descriptions = {
20
+ "crypto": """
21
+ Crypto Agent: Specializes in cryptocurrency market data, prices, trends,
22
+ market analysis, and blockchain information. Use this for queries about:
23
+ - Cryptocurrency prices and market data
24
+ - Trading volumes and market caps
25
+ - Trending coins and top gainers/losers
26
+ - Blockchain statistics
27
+ - Crypto market analysis
28
+ """,
29
+ "rag": """
30
+ RAG Agent: Specializes in document retrieval and question answering from
31
+ uploaded documents (PDF, TXT, DOCX) stored in ChromaDB Cloud. Use this for queries about:
32
+ - Questions about uploaded documents
33
+ - Information retrieval from your document library
34
+ - Document search and semantic analysis
35
+ - Knowledge base queries
36
+ - Content from stored files
37
+ - Finding specific information in documents
38
+ """
39
+ }
40
+
41
+ async def decide_agent(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
42
+ """Decide which agent should handle the query."""
43
+ try:
44
+ history_block = ""
45
+ if history:
46
+ recent_history = history[-6:]
47
+ history_lines: List[str] = []
48
+ for turn in recent_history:
49
+ user_text = turn.get("user", "").strip()
50
+ if user_text:
51
+ history_lines.append(f"User: {user_text}")
52
+ assistant_text = turn.get("assistant", "").strip()
53
+ if assistant_text:
54
+ history_lines.append(f"Assistant: {assistant_text}")
55
+ if history_lines:
56
+ history_block = "Recent conversation:\n" + "\n".join(history_lines) + "\n\n"
57
+
58
+ prompt = f"""You are a routing system for a multi-agent AI application.
59
+
60
+ {history_block}Available agents:
61
+ {self.agent_descriptions['crypto']}
62
+
63
+ {self.agent_descriptions['rag']}
64
+
65
+ User query: "{query}"
66
+
67
+ Analyze the query and determine which agent should handle it.
68
+ Respond with ONLY a JSON object in this exact format:
69
+ {{
70
+ "agent": "crypto" or "rag",
71
+ "confidence": 0.0-1.0,
72
+ "reasoning": "brief explanation"
73
+ }}
74
+
75
+ Choose carefully based on the query content."""
76
+
77
+ response = await self.model.ainvoke(prompt)
78
+ result_text = response.content.strip() if isinstance(response.content, str) else str(response.content)
79
+
80
+ # Extract JSON from response
81
+ import json
82
+ # Remove markdown code blocks if present
83
+ if "```json" in result_text:
84
+ result_text = result_text.split("```json")[1].split("```")[0].strip()
85
+ elif "```" in result_text:
86
+ result_text = result_text.split("```")[1].split("```")[0].strip()
87
+
88
+ decision = json.loads(result_text)
89
+
90
+ print(f"\n🎯 {self.name} Decision:")
91
+ print(f" Agent: {decision['agent']}")
92
+ print(f" Confidence: {decision['confidence']}")
93
+ print(f" Reasoning: {decision['reasoning']}")
94
+
95
+ return decision
96
+
97
+ except Exception as e:
98
+ print(f"❌ Error in decision maker: {e}")
99
+ # Default to crypto agent if decision fails
100
+ return {
101
+ "agent": "crypto",
102
+ "confidence": 0.5,
103
+ "reasoning": f"Error in decision maker, defaulting to crypto agent: {str(e)}"
104
+ }
OLD/misc/rag_docker.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG Agent using Chroma MCP Server via LangChain MCP adapters."""
2
+ import json
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from config import config
10
+ from file_processors import process_document
11
+ from langchain_core.tools import BaseTool
12
+ from langchain_google_genai import ChatGoogleGenerativeAI
13
+ from langchain_mcp_adapters.client import MultiServerMCPClient
14
+
15
+
16
+ class RAGAgentMCP:
17
+ """Agent specialized in document retrieval and Q&A using Chroma MCP Server."""
18
+
19
+ @staticmethod
20
+ def _to_text(payload: Any) -> str:
21
+ """Convert tool or model output into a displayable string."""
22
+ if isinstance(payload, str):
23
+ return payload
24
+ try:
25
+ return json.dumps(payload, ensure_ascii=False)
26
+ except TypeError:
27
+ return str(payload)
28
+
29
+ def __init__(self):
30
+ self.name = "RAG Agent (MCP)"
31
+ self.description = "Document storage, retrieval, and question answering expert using Chroma MCP Server"
32
+ self.mcp_client: Optional[MultiServerMCPClient] = None
33
+ self.model: Optional[ChatGoogleGenerativeAI] = None
34
+ self.tools: List[BaseTool] = []
35
+ self.tool_map: Dict[str, BaseTool] = {}
36
+
37
+ async def initialize(self) -> None:
38
+ """Initialize the agent with Chroma MCP server."""
39
+ print(f"πŸ”§ Initializing {self.name}...")
40
+
41
+ try:
42
+ # Connect to Chroma MCP Server for tool access
43
+ print(f" πŸ“‘ Connecting to Chroma MCP Server...")
44
+ chroma_connection = self._build_chroma_connection()
45
+ self.mcp_client = MultiServerMCPClient({"chroma": chroma_connection})
46
+
47
+ # Get tools from MCP server
48
+ self.tools = await self.mcp_client.get_tools(server_name="chroma")
49
+ if not self.tools:
50
+ raise RuntimeError("No tools returned by Chroma MCP server")
51
+
52
+ self.tool_map = {tool.name: tool for tool in self.tools}
53
+
54
+ print(f" βœ… Connected to Chroma MCP Server with {len(self.tools)} tools")
55
+
56
+ # Initialize Gemini model for answer generation
57
+ self.model = ChatGoogleGenerativeAI(
58
+ model="gemini-2.5-flash",
59
+ temperature=0.1,
60
+ google_api_key=config.GOOGLE_API_KEY,
61
+ )
62
+
63
+ print(f" βœ… {self.name} ready!")
64
+
65
+ except Exception as e:
66
+ import traceback
67
+ print(f" ❌ Error initializing {self.name}: {e}")
68
+ print(f" πŸ“‹ Full error details:")
69
+ traceback.print_exc()
70
+ raise
71
+
72
+ async def add_document(
73
+ self,
74
+ file_path: str,
75
+ metadata: Optional[Dict[str, Any]] = None,
76
+ progress_callback: Optional[callable] = None
77
+ ) -> Dict[str, Any]:
78
+ """Add a document to ChromaDB via MCP server."""
79
+ try:
80
+ doc_path = Path(file_path)
81
+
82
+ # Validate file type
83
+ if doc_path.suffix.lower() not in config.ALLOWED_FILE_TYPES:
84
+ return {
85
+ "success": False,
86
+ "error": f"Unsupported file type: {doc_path.suffix}. Allowed: {config.ALLOWED_FILE_TYPES}"
87
+ }
88
+
89
+ print(f"\nπŸ“„ Processing {doc_path.suffix.upper()}: {doc_path.name}")
90
+
91
+ if progress_callback:
92
+ progress_callback(0.1, "Extracting text from document...")
93
+
94
+ # Process document using file_processors
95
+ doc_info = process_document(doc_path, chunk_size=500, overlap=50)
96
+
97
+ if progress_callback:
98
+ progress_callback(0.4, f"Extracted {doc_info['num_chunks']} chunks...")
99
+
100
+ # Generate document ID
101
+ doc_id = doc_path.stem
102
+
103
+ # Prepare metadata
104
+ doc_metadata = {
105
+ "filename": doc_info["filename"],
106
+ "file_type": doc_info["file_type"],
107
+ "file_size": doc_info["file_size"],
108
+ "num_chunks": doc_info["num_chunks"],
109
+ "source": "user_upload",
110
+ **(metadata or {})
111
+ }
112
+
113
+ # Upload via Chroma MCP Server
114
+ if progress_callback:
115
+ progress_callback(0.6, "Uploading via Chroma MCP Server...")
116
+
117
+ # Prepare data for MCP add_documents tool
118
+ chunks = doc_info["chunks"]
119
+ ids = [f"{doc_id}_chunk_{i}" for i in range(len(chunks))]
120
+ metadatas = [
121
+ {**doc_metadata, "chunk_index": i, "chunk_id": ids[i]}
122
+ for i in range(len(chunks))
123
+ ]
124
+
125
+ add_tool = (
126
+ self.tool_map.get("chroma_add_documents")
127
+ or self.tool_map.get("add_documents")
128
+ )
129
+ if not add_tool:
130
+ raise KeyError("add_documents tool not available in Chroma MCP")
131
+
132
+ tool_payload = {
133
+ "collection_name": "gemini-embed", # Use dedicated Gemini collection
134
+ "documents": chunks,
135
+ "ids": ids,
136
+ "metadatas": metadatas,
137
+ }
138
+
139
+ result = await add_tool.ainvoke(tool_payload)
140
+
141
+ if isinstance(result, dict) and result.get("success"):
142
+ print(f" βœ… Added via MCP: {len(chunks)} chunks from {doc_path.name}")
143
+ else:
144
+ raise ValueError(result)
145
+
146
+ if progress_callback:
147
+ progress_callback(1.0, "Upload complete!")
148
+
149
+ return {
150
+ "success": True,
151
+ "document_id": doc_id,
152
+ "filename": doc_path.name,
153
+ "file_type": doc_info["file_type"],
154
+ "chunks_added": len(chunks),
155
+ }
156
+
157
+ except Exception as e:
158
+ print(f" ❌ Error adding document: {e}")
159
+ import traceback
160
+ traceback.print_exc()
161
+ return {
162
+ "success": False,
163
+ "error": str(e)
164
+ }
165
+
166
+ def _build_chroma_connection(self) -> Dict[str, Any]:
167
+ """Create connection configuration for the Chroma MCP server."""
168
+ return {
169
+ "transport": "docker",
170
+ "command": "mcp",
171
+ "args": ["gateway", "run", "--servers=chromadb"],
172
+ }
OLD/searchagent_ddg_api.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DuckDuckGo Search Agent for web search capabilities."""
2
+ import asyncio
3
+ from typing import Dict, Any, Optional, List
4
+ from langchain_google_genai import ChatGoogleGenerativeAI
5
+ from langchain_core.messages import HumanMessage, SystemMessage
6
+ from ddgs import DDGS
7
+ from config import config
8
+
9
+
10
+ class SearchAgentMCP:
11
+ """
12
+ Agent for performing web searches using DuckDuckGo.
13
+ Follows the same pattern as other MCP agents in the system.
14
+ """
15
+
16
+ def __init__(self):
17
+ """Initialize the DuckDuckGo search agent."""
18
+ self.llm = None
19
+ self.ddgs = DDGS()
20
+ self.initialized = False
21
+
22
+ async def initialize(self):
23
+ """Initialize the agent with LLM."""
24
+ if not self.initialized:
25
+ self.llm = ChatGoogleGenerativeAI(
26
+ model="gemini-2.5-flash",
27
+ temperature=0.3,
28
+ google_api_key=config.GOOGLE_API_KEY,
29
+ )
30
+ self.initialized = True
31
+ print("βœ… Search Agent initialized")
32
+
33
+ async def search_web(
34
+ self,
35
+ query: str,
36
+ max_results: int = 4,
37
+ region: str = "wt-wt"
38
+ ) -> Dict[str, Any]:
39
+ """
40
+ Perform a web search using DuckDuckGo.
41
+
42
+ Args:
43
+ query: Search query
44
+ max_results: Maximum number of results to return (default: 5)
45
+ region: Region for search (default: wt-wt for worldwide)
46
+
47
+ Returns:
48
+ Dictionary with search results
49
+ """
50
+ try:
51
+ # Perform search
52
+ results = []
53
+ # Pass query as positional argument (API changed in newer versions)
54
+ search_results = list(self.ddgs.text(
55
+ query,
56
+ region=region,
57
+ safesearch='moderate',
58
+ max_results=max_results
59
+ ))
60
+
61
+ for result in search_results:
62
+ results.append({
63
+ "title": result.get("title", ""),
64
+ "link": result.get("href", ""),
65
+ "snippet": result.get("body", "")
66
+ })
67
+
68
+ return {
69
+ "success": True,
70
+ "query": query,
71
+ "results": results,
72
+ "count": len(results)
73
+ }
74
+
75
+ except Exception as e:
76
+ return {
77
+ "success": False,
78
+ "error": str(e),
79
+ "query": query,
80
+ "results": []
81
+ }
82
+
83
+ async def search_news(
84
+ self,
85
+ query: str,
86
+ max_results: int = 4
87
+ ) -> Dict[str, Any]:
88
+ """
89
+ Search for news articles using DuckDuckGo.
90
+
91
+ Args:
92
+ query: Search query
93
+ max_results: Maximum number of results
94
+
95
+ Returns:
96
+ Dictionary with news results
97
+ """
98
+ try:
99
+ results = []
100
+ # Pass query as positional argument (API changed in newer versions)
101
+ news_results = list(self.ddgs.news(
102
+ query,
103
+ safesearch='moderate',
104
+ max_results=max_results
105
+ ))
106
+
107
+ for result in news_results:
108
+ results.append({
109
+ "title": result.get("title", ""),
110
+ "link": result.get("url", ""),
111
+ "snippet": result.get("body", ""),
112
+ "source": result.get("source", ""),
113
+ "date": result.get("date", "")
114
+ })
115
+
116
+ return {
117
+ "success": True,
118
+ "query": query,
119
+ "results": results,
120
+ "count": len(results)
121
+ }
122
+
123
+ except Exception as e:
124
+ return {
125
+ "success": False,
126
+ "error": str(e),
127
+ "query": query,
128
+ "results": []
129
+ }
130
+
131
+ def _format_search_results(self, search_data: Dict[str, Any]) -> str:
132
+ """
133
+ Format search results into a readable string.
134
+
135
+ Args:
136
+ search_data: Raw search results
137
+
138
+ Returns:
139
+ Formatted string of results
140
+ """
141
+ if not search_data.get("success"):
142
+ return f"Search failed: {search_data.get('error', 'Unknown error')}"
143
+
144
+ results = search_data.get("results", [])
145
+ if not results:
146
+ return f"No results found for query: {search_data.get('query')}"
147
+
148
+ formatted = f"Found {len(results)} results for '{search_data.get('query')}':\n\n"
149
+
150
+ for idx, result in enumerate(results, 1):
151
+ formatted += f"{idx}. {result.get('title', 'No title')}\n"
152
+ formatted += f" URL: {result.get('link', 'N/A')}\n"
153
+ formatted += f" {result.get('snippet', 'No description')}\n"
154
+
155
+ # Add source and date for news results
156
+ if result.get('source'):
157
+ formatted += f" Source: {result['source']}"
158
+ if result.get('date'):
159
+ formatted += f" | Date: {result['date']}"
160
+ formatted += "\n"
161
+
162
+ formatted += "\n"
163
+
164
+ return formatted
165
+
166
+ async def process(
167
+ self,
168
+ query: str,
169
+ history: Optional[List[Dict[str, str]]] = None
170
+ ) -> Dict[str, Any]:
171
+ """
172
+ Process a search query with LLM interpretation.
173
+
174
+ Args:
175
+ query: User's search query
176
+ history: Optional conversation history
177
+
178
+ Returns:
179
+ Dictionary with agent response
180
+ """
181
+ try:
182
+ if not self.initialized:
183
+ await self.initialize()
184
+
185
+ # Determine search type and execute
186
+ search_type = await self._determine_search_type(query)
187
+
188
+ if search_type == "news":
189
+ search_results = await self.search_news(query, max_results=5)
190
+ else:
191
+ search_results = await self.search_web(query, max_results=5)
192
+
193
+ # Format results
194
+ formatted_results = self._format_search_results(search_results)
195
+
196
+ # Generate LLM response with context
197
+ system_prompt = """You are a web search assistant. Your role is to:
198
+ 1. Interpret web search results
199
+ 2. Synthesize information from multiple sources
200
+ 3. Provide clear, accurate answers with source attribution
201
+ 4. Highlight the most relevant findings
202
+ 5. Note any limitations or conflicting information
203
+
204
+ Always cite sources by referring to their result numbers (e.g., "According to result #1...")."""
205
+
206
+ user_prompt = f"""User Query: {query}
207
+
208
+ Search Results:
209
+ {formatted_results}
210
+
211
+ Based on these search results, provide a comprehensive answer to the user's query.
212
+ Include relevant details and cite your sources."""
213
+
214
+ response = await self.llm.ainvoke([
215
+ SystemMessage(content=system_prompt),
216
+ HumanMessage(content=user_prompt)
217
+ ])
218
+
219
+ return {
220
+ "success": True,
221
+ "response": response.content,
222
+ "raw_results": search_results,
223
+ "search_type": search_type,
224
+ "metadata": {
225
+ "results_count": search_results.get("count", 0),
226
+ "query": query
227
+ }
228
+ }
229
+
230
+ except Exception as e:
231
+ error_msg = f"Error processing search query: {str(e)}"
232
+ return {
233
+ "success": False,
234
+ "error": error_msg,
235
+ "response": f"I encountered an error while searching: {str(e)}"
236
+ }
237
+
238
+ async def _determine_search_type(self, query: str) -> str:
239
+ """
240
+ Determine if query is asking for news or general web search.
241
+
242
+ Args:
243
+ query: User query
244
+
245
+ Returns:
246
+ "news" or "web"
247
+ """
248
+ query_lower = query.lower()
249
+ news_keywords = ["news", "latest", "recent", "today", "breaking", "update"]
250
+
251
+ if any(keyword in query_lower for keyword in news_keywords):
252
+ return "news"
253
+
254
+ return "web"
255
+
256
+ async def cleanup(self):
257
+ """Cleanup resources."""
258
+ if self.initialized:
259
+ # DuckDuckGo search doesn't need explicit cleanup
260
+ self.initialized = False
261
+ print("🧹 Search Agent cleanup complete")
database_schema.sql ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
2
+
3
+ -- Table: stock_transactions
4
+ -- Stores all buy/sell transactions for the portfolio
5
+ CREATE TABLE IF NOT EXISTS stock_transactions (
6
+ id SERIAL PRIMARY KEY,
7
+ transaction_id UUID DEFAULT uuid_generate_v4() UNIQUE NOT NULL,
8
+ symbol VARCHAR(10) NOT NULL,
9
+ transaction_type VARCHAR(10) NOT NULL CHECK (transaction_type IN ('BUY', 'SELL')),
10
+ quantity DECIMAL(15, 4) NOT NULL CHECK (quantity > 0),
11
+ price DECIMAL(15, 4) NOT NULL CHECK (price >= 0),
12
+ transaction_date DATE NOT NULL,
13
+ notes TEXT,
14
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
15
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
16
+ );
17
+
18
+ -- Table: portfolio_positions
19
+ -- Stores current aggregated positions (calculated from transactions)
20
+ CREATE TABLE IF NOT EXISTS portfolio_positions (
21
+ symbol VARCHAR(10) PRIMARY KEY,
22
+ total_quantity DECIMAL(15, 4) NOT NULL CHECK (total_quantity >= 0),
23
+ avg_cost_basis DECIMAL(15, 4) NOT NULL CHECK (avg_cost_basis >= 0),
24
+ first_purchase_date DATE,
25
+ last_transaction_date DATE,
26
+ total_invested DECIMAL(15, 2) NOT NULL DEFAULT 0,
27
+ realized_gains DECIMAL(15, 2) DEFAULT 0,
28
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
29
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
30
+ );
31
+
32
+ -- Table: portfolio_snapshots
33
+ -- Stores historical portfolio value snapshots for performance tracking
34
+ CREATE TABLE IF NOT EXISTS portfolio_snapshots (
35
+ id SERIAL PRIMARY KEY,
36
+ snapshot_date DATE NOT NULL,
37
+ total_value DECIMAL(15, 2) NOT NULL,
38
+ total_cost_basis DECIMAL(15, 2) NOT NULL,
39
+ total_gain_loss DECIMAL(15, 2) NOT NULL,
40
+ total_gain_loss_pct DECIMAL(10, 4) NOT NULL,
41
+ num_positions INTEGER NOT NULL,
42
+ snapshot_data JSONB, -- Store detailed position data
43
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
44
+ );
45
+
46
+ -- Table: stock_metadata
47
+ -- Cache stock information to reduce API calls
48
+ CREATE TABLE IF NOT EXISTS stock_metadata (
49
+ symbol VARCHAR(10) PRIMARY KEY,
50
+ company_name VARCHAR(255),
51
+ sector VARCHAR(100),
52
+ industry VARCHAR(100),
53
+ market_cap BIGINT,
54
+ last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
55
+ );
56
+
57
+ -- Indexes for performance optimization
58
+ CREATE INDEX IF NOT EXISTS idx_transactions_symbol ON stock_transactions(symbol);
59
+ CREATE INDEX IF NOT EXISTS idx_transactions_date ON stock_transactions(transaction_date DESC);
60
+ CREATE INDEX IF NOT EXISTS idx_transactions_type ON stock_transactions(transaction_type);
61
+ CREATE INDEX IF NOT EXISTS idx_snapshots_date ON portfolio_snapshots(snapshot_date DESC);
62
+ CREATE INDEX IF NOT EXISTS idx_stock_metadata_sector ON stock_metadata(sector);
63
+
64
+ -- Function to update the updated_at timestamp
65
+ CREATE OR REPLACE FUNCTION update_updated_at_column()
66
+ RETURNS TRIGGER AS $$
67
+ BEGIN
68
+ NEW.updated_at = CURRENT_TIMESTAMP;
69
+ RETURN NEW;
70
+ END;
71
+ $$ LANGUAGE plpgsql;
72
+
73
+ -- Triggers to automatically update updated_at
74
+ CREATE TRIGGER update_stock_transactions_updated_at
75
+ BEFORE UPDATE ON stock_transactions
76
+ FOR EACH ROW
77
+ EXECUTE FUNCTION update_updated_at_column();
78
+
79
+ CREATE TRIGGER update_portfolio_positions_updated_at
80
+ BEFORE UPDATE ON portfolio_positions
81
+ FOR EACH ROW
82
+ EXECUTE FUNCTION update_updated_at_column();
83
+
84
+ CREATE TRIGGER update_stock_metadata_updated_at
85
+ BEFORE UPDATE ON stock_metadata
86
+ FOR EACH ROW
87
+ EXECUTE FUNCTION update_updated_at_column();
88
+
89
+ -- Function to recalculate portfolio positions
90
+ -- This ensures positions are always in sync with transactions
91
+ CREATE OR REPLACE FUNCTION recalculate_position(p_symbol VARCHAR)
92
+ RETURNS VOID AS $$
93
+ DECLARE
94
+ v_total_quantity DECIMAL(15, 4);
95
+ v_avg_cost_basis DECIMAL(15, 4);
96
+ v_total_invested DECIMAL(15, 2);
97
+ v_realized_gains DECIMAL(15, 2);
98
+ v_first_purchase DATE;
99
+ v_last_transaction DATE;
100
+ BEGIN
101
+ -- Calculate position from transactions
102
+ WITH position_calc AS (
103
+ SELECT
104
+ SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE -quantity END) as net_quantity,
105
+ SUM(CASE WHEN transaction_type = 'BUY' THEN quantity * price ELSE 0 END) as total_cost,
106
+ SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) as total_bought,
107
+ MIN(CASE WHEN transaction_type = 'BUY' THEN transaction_date END) as first_buy,
108
+ MAX(transaction_date) as last_trans,
109
+ SUM(CASE WHEN transaction_type = 'SELL' THEN quantity * price ELSE 0 END) -
110
+ SUM(CASE WHEN transaction_type = 'SELL' THEN quantity *
111
+ (SELECT AVG(price) FROM stock_transactions WHERE symbol = p_symbol AND transaction_type = 'BUY')
112
+ ELSE 0 END) as realized_gain
113
+ FROM stock_transactions
114
+ WHERE symbol = p_symbol
115
+ )
116
+ SELECT
117
+ net_quantity,
118
+ CASE WHEN total_bought > 0 THEN total_cost / total_bought ELSE 0 END,
119
+ total_cost,
120
+ COALESCE(realized_gain, 0),
121
+ first_buy,
122
+ last_trans
123
+ INTO v_total_quantity, v_avg_cost_basis, v_total_invested, v_realized_gains, v_first_purchase, v_last_transaction
124
+ FROM position_calc;
125
+
126
+ -- Update or delete position
127
+ IF v_total_quantity > 0 THEN
128
+ INSERT INTO portfolio_positions (
129
+ symbol, total_quantity, avg_cost_basis, total_invested,
130
+ realized_gains, first_purchase_date, last_transaction_date
131
+ )
132
+ VALUES (
133
+ p_symbol, v_total_quantity, v_avg_cost_basis, v_total_invested,
134
+ v_realized_gains, v_first_purchase, v_last_transaction
135
+ )
136
+ ON CONFLICT (symbol) DO UPDATE SET
137
+ total_quantity = EXCLUDED.total_quantity,
138
+ avg_cost_basis = EXCLUDED.avg_cost_basis,
139
+ total_invested = EXCLUDED.total_invested,
140
+ realized_gains = EXCLUDED.realized_gains,
141
+ first_purchase_date = EXCLUDED.first_purchase_date,
142
+ last_transaction_date = EXCLUDED.last_transaction_date;
143
+ ELSE
144
+ -- If position is fully closed, delete it
145
+ DELETE FROM portfolio_positions WHERE symbol = p_symbol;
146
+ END IF;
147
+ END;
148
+ $$ LANGUAGE plpgsql;
149
+
150
+ -- Trigger to automatically recalculate positions when transactions change
151
+ CREATE OR REPLACE FUNCTION trigger_recalculate_position()
152
+ RETURNS TRIGGER AS $$
153
+ BEGIN
154
+ IF TG_OP = 'DELETE' THEN
155
+ PERFORM recalculate_position(OLD.symbol);
156
+ RETURN OLD;
157
+ ELSE
158
+ PERFORM recalculate_position(NEW.symbol);
159
+ RETURN NEW;
160
+ END IF;
161
+ END;
162
+ $$ LANGUAGE plpgsql;
163
+
164
+ CREATE TRIGGER auto_recalculate_position
165
+ AFTER INSERT OR UPDATE OR DELETE ON stock_transactions
166
+ FOR EACH ROW
167
+ EXECUTE FUNCTION trigger_recalculate_position();
168
+
169
+ -- View: current_portfolio_summary
170
+ -- Provides a quick overview of the portfolio
171
+ CREATE OR REPLACE VIEW current_portfolio_summary AS
172
+ SELECT
173
+ COUNT(*) as total_positions,
174
+ SUM(total_quantity) as total_shares,
175
+ SUM(total_invested) as total_invested,
176
+ SUM(realized_gains) as total_realized_gains
177
+ FROM portfolio_positions;
docker-compose.yml ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ # Main Multi-Agent Application (Streamlit UI + FastAPI Backend)
5
+ app:
6
+ build:
7
+ context: .
8
+ dockerfile: Dockerfile
9
+ container_name: multi-agent-app
10
+ ports:
11
+ - "8501:8501" # Streamlit UI
12
+ - "8000:8000" # FastAPI Backend
13
+ environment:
14
+ # API Keys
15
+ GOOGLE_API_KEY: ${GOOGLE_API_KEY}
16
+ COINGECKO_API_KEY: ${COINGECKO_API_KEY}
17
+ ALPHA_VANTAGE_API_KEY: ${ALPHA_VANTAGE_API_KEY}
18
+
19
+ # ChromaDB Cloud Configuration
20
+ CHROMA_API_KEY: ${CHROMA_API_KEY}
21
+ CHROMA_TENANT: ${CHROMA_TENANT}
22
+ CHROMA_DATABASE: ${CHROMA_DATABASE}
23
+ CHROMA_CLOUD_HOST: ${CHROMA_CLOUD_HOST:-api.trychroma.com}
24
+ CHROMA_EMBEDDING_FUNCTION: ${CHROMA_EMBEDDING_FUNCTION:-default}
25
+ DOCUMENTS_COLLECTION: ${DOCUMENTS_COLLECTION:-mcp-test}
26
+
27
+ # Google Cloud SQL Configuration
28
+ GCP_PROJECT_ID: ${GCP_PROJECT_ID}
29
+ CLOUD_SQL_REGION: ${CLOUD_SQL_REGION}
30
+ CLOUD_SQL_INSTANCE: ${CLOUD_SQL_INSTANCE}
31
+ CLOUD_SQL_DB_NAME: ${CLOUD_SQL_DB_NAME:-finance_tracker}
32
+ CLOUD_SQL_DB_USER: ${CLOUD_SQL_DB_USER}
33
+ CLOUD_SQL_DB_PASS: ${CLOUD_SQL_DB_PASS}
34
+
35
+ # MCP Toolbox connection (internal network)
36
+ MCP_TOOLBOX_SERVER_URL: http://mcp-toolbox:5000
37
+
38
+ # UI Configuration
39
+ MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-50}
40
+
41
+ depends_on:
42
+ - mcp-toolbox
43
+
44
+ networks:
45
+ - mcp-network
46
+
47
+ restart: unless-stopped
48
+
49
+ healthcheck:
50
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
51
+ interval: 30s
52
+ timeout: 10s
53
+ retries: 3
54
+ start_period: 90s
55
+
56
+ # MCP Toolbox for Cloud SQL Access
57
+ mcp-toolbox:
58
+ build:
59
+ context: .
60
+ dockerfile: Dockerfile.toolbox
61
+ container_name: mcp-toolbox-server
62
+ ports:
63
+ - "5000:5000"
64
+ environment:
65
+ # Cloud SQL Connection Details
66
+ CLOUD_SQL_POSTGRES_PROJECT: ${GCP_PROJECT_ID}
67
+ CLOUD_SQL_POSTGRES_REGION: ${CLOUD_SQL_REGION}
68
+ CLOUD_SQL_POSTGRES_INSTANCE: ${CLOUD_SQL_INSTANCE}
69
+ CLOUD_SQL_POSTGRES_DATABASE: ${CLOUD_SQL_DB_NAME}
70
+ CLOUD_SQL_POSTGRES_USER: ${CLOUD_SQL_DB_USER}
71
+ CLOUD_SQL_POSTGRES_PASSWORD: ${CLOUD_SQL_DB_PASS}
72
+
73
+ # Google Cloud credentials path (used by Cloud SQL connector)
74
+ GOOGLE_APPLICATION_CREDENTIALS: /app/credentials/credentials.json
75
+
76
+ volumes:
77
+ # Mount Application Default Credentials to the location MCP Toolbox expects
78
+ # Linux/Mac: ${HOME}/.config/gcloud/application_default_credentials.json
79
+ # Windows: ${APPDATA}/gcloud/application_default_credentials.json
80
+ - ${APPDATA}/gcloud/application_default_credentials.json:/app/credentials/credentials.json:ro
81
+
82
+ networks:
83
+ - mcp-network
84
+
85
+ restart: unless-stopped
86
+
87
+ # Note: MCP Toolbox doesn't expose a health endpoint
88
+ # Check if container is running with: docker-compose ps
89
+
90
+ networks:
91
+ mcp-network:
92
+ name: mcp-toolbox-network
93
+ driver: bridge
docker/Dockerfile ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage Dockerfile for Multi-Agent Assistant
2
+ # Supports both Python (uvx) and Node.js (npx) MCP servers
3
+
4
+ FROM python:3.11-slim as base
5
+
6
+ # Install system dependencies including Node.js
7
+ RUN apt-get update && apt-get install -y \
8
+ ca-certificates \
9
+ curl \
10
+ git \
11
+ build-essential \
12
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
13
+ && apt-get install -y nodejs \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Verify installations
17
+ RUN python --version && node --version && npm --version
18
+
19
+ # Set working directory
20
+ WORKDIR /app
21
+
22
+ # Copy requirements first (for layer caching)
23
+ COPY requirements.txt .
24
+
25
+ # Install Python dependencies
26
+ RUN pip install --no-cache-dir --upgrade pip && \
27
+ pip install --no-cache-dir -r requirements.txt
28
+
29
+ # Install npx-based MCP servers globally (for CoinGecko)
30
+ RUN npm install -g @coingecko/coingecko-mcp
31
+
32
+ # Copy application code with new structure
33
+ COPY src/ ./src/
34
+ COPY ui/ ./ui/
35
+ COPY tests/ ./tests/
36
+
37
+ # Copy startup script
38
+ COPY scripts/start.sh ./start.sh
39
+
40
+ # Create directory for temporary file uploads
41
+ RUN mkdir -p /tmp/uploads && chmod 777 /tmp/uploads
42
+
43
+ # Make startup script executable
44
+ RUN chmod +x /app/start.sh
45
+
46
+ # Copy Streamlit config (includes dark theme)
47
+ RUN mkdir -p /root/.streamlit
48
+ COPY .streamlit/config.toml /root/.streamlit/config.toml
49
+
50
+ # Additional Streamlit config for production
51
+ RUN echo '\n[browser]\ngatherUsageStats = false\n' >> /root/.streamlit/config.toml
52
+
53
+
54
+ # Expose ports
55
+ # 8501 - Streamlit UI
56
+ # 8000 - FastAPI Backend
57
+ EXPOSE 8501 8000
58
+
59
+ # Health check - check both FastAPI and Streamlit
60
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
61
+ CMD curl -f http://localhost:8000/health && curl -f http://localhost:8501/_stcore/health || exit 1
62
+
63
+ # Use startup script to run both FastAPI and Streamlit
64
+ CMD ["/app/start.sh"]
docker/Dockerfile.toolbox ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for MCP Toolbox Server
2
+ # Runs MCP Toolbox as an HTTP server for Cloud SQL PostgreSQL access
3
+
4
+ FROM debian:bookworm-slim
5
+
6
+ # Install dependencies
7
+ RUN apt-get update && apt-get install -y \
8
+ ca-certificates \
9
+ curl \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Set MCP Toolbox version
13
+ ARG TOOLBOX_VERSION=v0.18.0
14
+
15
+ # Detect architecture and download appropriate binary
16
+ RUN ARCH=$(dpkg --print-architecture) && \
17
+ if [ "$ARCH" = "amd64" ]; then \
18
+ TOOLBOX_ARCH="amd64"; \
19
+ elif [ "$ARCH" = "arm64" ]; then \
20
+ TOOLBOX_ARCH="arm64"; \
21
+ else \
22
+ echo "Unsupported architecture: $ARCH" && exit 1; \
23
+ fi && \
24
+ curl -L "https://storage.googleapis.com/genai-toolbox/${TOOLBOX_VERSION}/linux/${TOOLBOX_ARCH}/toolbox" \
25
+ -o /usr/local/bin/toolbox && \
26
+ chmod +x /usr/local/bin/toolbox
27
+
28
+ # Verify installation
29
+ RUN toolbox --version
30
+
31
+ # Create credentials directory
32
+ RUN mkdir -p /app/credentials
33
+
34
+ # Copy entrypoint script
35
+ COPY docker/entrypoint-toolbox.sh /entrypoint.sh
36
+ RUN chmod +x /entrypoint.sh
37
+
38
+ # Expose port for HTTP server
39
+ EXPOSE 5000
40
+
41
+ # Environment variables for Cloud SQL connection
42
+ # These will be provided via docker-compose or docker run
43
+ ENV CLOUD_SQL_POSTGRES_PROJECT="" \
44
+ CLOUD_SQL_POSTGRES_REGION="" \
45
+ CLOUD_SQL_POSTGRES_INSTANCE="" \
46
+ CLOUD_SQL_POSTGRES_DATABASE="" \
47
+ CLOUD_SQL_POSTGRES_USER="" \
48
+ CLOUD_SQL_POSTGRES_PASSWORD="" \
49
+ GCP_SERVICE_ACCOUNT_JSON=""
50
+
51
+ # Run MCP Toolbox via entrypoint script (handles GCP credentials)
52
+ ENTRYPOINT ["/entrypoint.sh"]
docker/entrypoint-hf.sh ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Entrypoint script for Hugging Face Spaces deployment
3
+ # Launches the Gradio multi-agent assistant
4
+
5
+ set -e
6
+
7
+ echo "=================================================="
8
+ echo "πŸš€ Starting PortfolioMind Multi-Agent Assistant"
9
+ echo "=================================================="
10
+
11
+ # Validate required environment variables
12
+ echo "πŸ“‹ Validating configuration..."
13
+
14
+ REQUIRED_VARS=(
15
+ "GOOGLE_API_KEY"
16
+ "CHROMA_API_KEY"
17
+ "CHROMA_TENANT"
18
+ "CHROMA_DATABASE"
19
+ "ALPHA_VANTAGE_API_KEY"
20
+ )
21
+
22
+ MISSING_VARS=()
23
+ for var in "${REQUIRED_VARS[@]}"; do
24
+ if [ -z "${!var}" ]; then
25
+ MISSING_VARS+=("$var")
26
+ fi
27
+ done
28
+
29
+ if [ ${#MISSING_VARS[@]} -gt 0 ]; then
30
+ echo "❌ Missing required environment variables:"
31
+ for var in "${MISSING_VARS[@]}"; do
32
+ echo " - $var"
33
+ done
34
+ echo ""
35
+ echo "Please configure these in Hugging Face Space Settings > Variables and Secrets"
36
+ exit 1
37
+ fi
38
+
39
+ echo "βœ… All required environment variables are set"
40
+
41
+ # Optional variables (log if present)
42
+ echo ""
43
+ echo "πŸ“Š Optional services status:"
44
+ [ -n "$COINGECKO_API_KEY" ] && echo " βœ“ CoinGecko API configured" || echo " ⚠ CoinGecko API not configured (using free tier)"
45
+ [ -n "$GCP_PROJECT_ID" ] && echo " βœ“ Google Cloud SQL configured" || echo " ⚠ Google Cloud SQL not configured"
46
+ [ -n "$MCP_TOOLBOX_SERVER_URL" ] && echo " βœ“ MCP Toolbox URL: $MCP_TOOLBOX_SERVER_URL" || echo " ⚠ MCP Toolbox not configured"
47
+
48
+ echo ""
49
+ echo "🎯 Starting Gradio application on port 7860..."
50
+ echo "=================================================="
51
+ echo ""
52
+
53
+ # Launch Gradio app
54
+ cd /app
55
+ exec python -u ui/gradio_app.py
docker/entrypoint-toolbox.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Write GCP credentials from environment variable to file
5
+ if [ -n "$GCP_SERVICE_ACCOUNT_JSON" ]; then
6
+ echo "Configuring GCP credentials from environment variable..."
7
+ mkdir -p /app/credentials
8
+ echo "$GCP_SERVICE_ACCOUNT_JSON" > /app/credentials/credentials.json
9
+ export GOOGLE_APPLICATION_CREDENTIALS=/app/credentials/credentials.json
10
+ echo "GCP credentials configured successfully"
11
+ else
12
+ echo "WARNING: GCP_SERVICE_ACCOUNT_JSON not set. Cloud SQL authentication may fail."
13
+ fi
14
+
15
+ # Start MCP Toolbox
16
+ echo "Starting MCP Toolbox server on port 5000..."
17
+ exec toolbox --prebuilt cloud-sql-postgres --port 5000 --address 0.0.0.0
fastapi_testing.ipynb ADDED
@@ -0,0 +1,1209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# FastAPI Multi-Agent System Testing\n",
8
+ "\n",
9
+ "This notebook demonstrates how to test and interact with the FastAPI endpoints.\n",
10
+ "\n",
11
+ "## Prerequisites\n",
12
+ "\n",
13
+ "Make sure the FastAPI server is running:\n",
14
+ "```bash\n",
15
+ "python api.py\n",
16
+ "# OR\n",
17
+ "uvicorn api:app --host 0.0.0.0 --port 8000 --reload\n",
18
+ "```"
19
+ ]
20
+ },
21
+ {
22
+ "cell_type": "markdown",
23
+ "metadata": {},
24
+ "source": [
25
+ "## Setup and Imports"
26
+ ]
27
+ },
28
+ {
29
+ "cell_type": "code",
30
+ "execution_count": 2,
31
+ "metadata": {},
32
+ "outputs": [
33
+ {
34
+ "name": "stdout",
35
+ "output_type": "stream",
36
+ "text": [
37
+ "βœ… Imports successful\n",
38
+ "🌐 API Base URL: http://localhost:8000\n"
39
+ ]
40
+ }
41
+ ],
42
+ "source": [
43
+ "import requests\n",
44
+ "import json\n",
45
+ "import asyncio\n",
46
+ "import websockets\n",
47
+ "from pathlib import Path\n",
48
+ "import time\n",
49
+ "from IPython.display import display, Markdown, HTML\n",
50
+ "\n",
51
+ "# API Base URL\n",
52
+ "BASE_URL = \"http://localhost:8000\"\n",
53
+ "\n",
54
+ "print(\"βœ… Imports successful\")\n",
55
+ "print(f\"🌐 API Base URL: {BASE_URL}\")"
56
+ ]
57
+ },
58
+ {
59
+ "cell_type": "markdown",
60
+ "metadata": {},
61
+ "source": [
62
+ "## 1. Health Check\n",
63
+ "\n",
64
+ "Test if the API is running and all agents are initialized."
65
+ ]
66
+ },
67
+ {
68
+ "cell_type": "code",
69
+ "execution_count": 3,
70
+ "metadata": {},
71
+ "outputs": [
72
+ {
73
+ "name": "stdout",
74
+ "output_type": "stream",
75
+ "text": [
76
+ "πŸ₯ Health Check Response:\n",
77
+ " Status: healthy\n",
78
+ " Initialized: True\n",
79
+ "\n",
80
+ " Agent Status:\n",
81
+ " βœ… crypto: True\n",
82
+ " βœ… rag: True\n",
83
+ " βœ… stock: True\n",
84
+ " βœ… search: True\n",
85
+ " βœ… finance_tracker: True\n"
86
+ ]
87
+ }
88
+ ],
89
+ "source": [
90
+ "def test_health_check():\n",
91
+ " \"\"\"Test the health check endpoint.\"\"\"\n",
92
+ " try:\n",
93
+ " response = requests.get(f\"{BASE_URL}/health\")\n",
94
+ " response.raise_for_status()\n",
95
+ " data = response.json()\n",
96
+ " \n",
97
+ " print(\"πŸ₯ Health Check Response:\")\n",
98
+ " print(f\" Status: {data['status']}\")\n",
99
+ " print(f\" Initialized: {data['initialized']}\")\n",
100
+ " print(\"\\n Agent Status:\")\n",
101
+ " for agent, status in data['agents'].items():\n",
102
+ " emoji = \"βœ…\" if status else \"❌\"\n",
103
+ " print(f\" {emoji} {agent}: {status}\")\n",
104
+ " \n",
105
+ " return data\n",
106
+ " except Exception as e:\n",
107
+ " print(f\"❌ Error: {e}\")\n",
108
+ " return None\n",
109
+ "\n",
110
+ "health_data = test_health_check()"
111
+ ]
112
+ },
113
+ {
114
+ "cell_type": "markdown",
115
+ "metadata": {},
116
+ "source": [
117
+ "## 2. Root Endpoint\n",
118
+ "\n",
119
+ "Get API information and available endpoints."
120
+ ]
121
+ },
122
+ {
123
+ "cell_type": "code",
124
+ "execution_count": 4,
125
+ "metadata": {},
126
+ "outputs": [
127
+ {
128
+ "name": "stdout",
129
+ "output_type": "stream",
130
+ "text": [
131
+ "πŸ“‹ API Information:\n",
132
+ "{\n",
133
+ " \"message\": \"Multi-Agent Assistant API\",\n",
134
+ " \"version\": \"1.0.0\",\n",
135
+ " \"docs\": \"/docs\",\n",
136
+ " \"health\": \"/health\"\n",
137
+ "}\n"
138
+ ]
139
+ }
140
+ ],
141
+ "source": [
142
+ "def test_root_endpoint():\n",
143
+ " \"\"\"Test the root endpoint.\"\"\"\n",
144
+ " try:\n",
145
+ " response = requests.get(f\"{BASE_URL}/\")\n",
146
+ " response.raise_for_status()\n",
147
+ " data = response.json()\n",
148
+ " \n",
149
+ " print(\"πŸ“‹ API Information:\")\n",
150
+ " print(json.dumps(data, indent=2))\n",
151
+ " \n",
152
+ " return data\n",
153
+ " except Exception as e:\n",
154
+ " print(f\"❌ Error: {e}\")\n",
155
+ " return None\n",
156
+ "\n",
157
+ "api_info = test_root_endpoint()"
158
+ ]
159
+ },
160
+ {
161
+ "cell_type": "markdown",
162
+ "metadata": {},
163
+ "source": [
164
+ "## 3. Streaming Chat with Server-Sent Events (SSE)\n",
165
+ "\n",
166
+ "Test the streaming chat endpoint that returns real-time updates."
167
+ ]
168
+ },
169
+ {
170
+ "cell_type": "code",
171
+ "execution_count": 5,
172
+ "metadata": {},
173
+ "outputs": [
174
+ {
175
+ "name": "stdout",
176
+ "output_type": "stream",
177
+ "text": [
178
+ "πŸ’¬ Query: What is the current price of Bitcoin?\n",
179
+ "\n",
180
+ "πŸ“‘ Streaming Response:\n",
181
+ "================================================================================\n",
182
+ "\n",
183
+ "πŸ’­ Step 1: Reasoning\n",
184
+ " Thought: The user is asking for the current price of Bitcoin. The `CALL_CRYPTO` agent is designed to get cryptocurrency market data and prices. This is the most direct and appropriate tool to answer the query.\n",
185
+ " Action: CRYPTO\n",
186
+ " Justification: This action will directly retrieve the current price of Bitcoin, which is precisely what the user is asking for.\n",
187
+ "\n",
188
+ "πŸ”§ Calling Crypto Agent...\n",
189
+ "\n",
190
+ "πŸ“Š Crypto Agent Results:\n",
191
+ " The current price of Bitcoin is $95,748.\n",
192
+ "\n",
193
+ "πŸ’­ Step 2: Reasoning\n",
194
+ " Thought: I have already called the `CALL_CRYPTO` agent, which provided the current price of Bitcoin as $95,748. This directly answers the user's query. I have sufficient information to provide a final answer.\n",
195
+ " Action: FINISH\n",
196
+ " Justification: The `CALL_CRYPTO` agent has successfully retrieved the current price of Bitcoin, which was the user's original query. No further information is needed.\n",
197
+ "\n",
198
+ "✨ Final Answer:\n",
199
+ "--------------------------------------------------------------------------------\n",
200
+ "The current price of Bitcoin is $95,748.\n",
201
+ "--------------------------------------------------------------------------------\n",
202
+ "\n",
203
+ "================================================================================\n"
204
+ ]
205
+ }
206
+ ],
207
+ "source": [
208
+ "def test_streaming_chat(message, history=None):\n",
209
+ " \"\"\"Test streaming chat with SSE.\"\"\"\n",
210
+ " if history is None:\n",
211
+ " history = []\n",
212
+ " \n",
213
+ " payload = {\n",
214
+ " \"message\": message,\n",
215
+ " \"history\": history\n",
216
+ " }\n",
217
+ " \n",
218
+ " print(f\"πŸ’¬ Query: {message}\")\n",
219
+ " print(\"\\nπŸ“‘ Streaming Response:\")\n",
220
+ " print(\"=\" * 80)\n",
221
+ " \n",
222
+ " try:\n",
223
+ " response = requests.post(\n",
224
+ " f\"{BASE_URL}/api/v1/chat/stream\",\n",
225
+ " json=payload,\n",
226
+ " headers={\"Accept\": \"text/event-stream\"},\n",
227
+ " stream=True\n",
228
+ " )\n",
229
+ " response.raise_for_status()\n",
230
+ " \n",
231
+ " final_answer = \"\"\n",
232
+ " \n",
233
+ " for line in response.iter_lines():\n",
234
+ " if line:\n",
235
+ " line_str = line.decode('utf-8')\n",
236
+ " if line_str.startswith('data: '):\n",
237
+ " data_str = line_str[6:] # Remove 'data: ' prefix\n",
238
+ " try:\n",
239
+ " event = json.loads(data_str)\n",
240
+ " event_type = event.get('type', 'unknown')\n",
241
+ " \n",
242
+ " if event_type == 'thinking':\n",
243
+ " print(f\"\\nπŸ’­ Step {event.get('step', '?')}: Reasoning\")\n",
244
+ " print(f\" Thought: {event.get('thought', 'N/A')}\")\n",
245
+ " print(f\" Action: {event.get('action', 'N/A').upper()}\")\n",
246
+ " print(f\" Justification: {event.get('justification', 'N/A')}\")\n",
247
+ " \n",
248
+ " elif event_type == 'action':\n",
249
+ " agent = event.get('agent', 'unknown')\n",
250
+ " print(f\"\\nπŸ”§ Calling {agent.title()} Agent...\")\n",
251
+ " \n",
252
+ " elif event_type == 'observation':\n",
253
+ " agent = event.get('agent', 'unknown')\n",
254
+ " summary = event.get('summary', '')\n",
255
+ " print(f\"\\nπŸ“Š {agent.title()} Agent Results:\")\n",
256
+ " print(f\" {summary[:200]}...\" if len(summary) > 200 else f\" {summary}\")\n",
257
+ " \n",
258
+ " elif event_type == 'final_start':\n",
259
+ " print(\"\\n✨ Final Answer:\")\n",
260
+ " print(\"-\" * 80)\n",
261
+ " \n",
262
+ " elif event_type == 'final_token':\n",
263
+ " final_answer = event.get('accumulated', '')\n",
264
+ " \n",
265
+ " elif event_type == 'final_complete':\n",
266
+ " print(final_answer)\n",
267
+ " print(\"-\" * 80)\n",
268
+ " \n",
269
+ " elif event_type == 'error':\n",
270
+ " print(f\"\\n❌ Error: {event.get('error', 'Unknown error')}\")\n",
271
+ " \n",
272
+ " except json.JSONDecodeError:\n",
273
+ " continue\n",
274
+ " \n",
275
+ " print(\"\\n\" + \"=\" * 80)\n",
276
+ " return final_answer\n",
277
+ " \n",
278
+ " except Exception as e:\n",
279
+ " print(f\"\\n❌ Error: {e}\")\n",
280
+ " return None\n",
281
+ "\n",
282
+ "# Test with a simple crypto query\n",
283
+ "result = test_streaming_chat(\"What is the current price of Bitcoin?\")"
284
+ ]
285
+ },
286
+ {
287
+ "cell_type": "markdown",
288
+ "metadata": {},
289
+ "source": [
290
+ "### Test with Different Queries"
291
+ ]
292
+ },
293
+ {
294
+ "cell_type": "code",
295
+ "execution_count": 6,
296
+ "metadata": {},
297
+ "outputs": [
298
+ {
299
+ "name": "stdout",
300
+ "output_type": "stream",
301
+ "text": [
302
+ "πŸ’¬ Query: What are the top 5 cryptocurrencies by market cap?\n",
303
+ "\n",
304
+ "πŸ“‘ Streaming Response:\n",
305
+ "================================================================================\n",
306
+ "\n",
307
+ "πŸ’­ Step 1: Reasoning\n",
308
+ " Thought: The user is asking for a list of the top 5 cryptocurrencies based on their market capitalization. This is a specific query about cryptocurrency market data. I have an action called `CALL_CRYPTO` which is designed to get cryptocurrency market data, prices, and trends. This is the most direct and specialized tool for the job.\n",
309
+ " Action: CRYPTO\n",
310
+ " Justification: The `CALL_CRYPTO` action is the most appropriate tool to get real-time market capitalization data for cryptocurrencies, which is exactly what is needed to answer the user's query.\n",
311
+ "\n",
312
+ "πŸ”§ Calling Crypto Agent...\n",
313
+ "\n",
314
+ "πŸ“Š Crypto Agent Results:\n",
315
+ " The top 5 cryptocurrencies by market cap are:\n",
316
+ "\n",
317
+ "1. Bitcoin (BTC) - Market Cap: $2,025,171,098,463 - Price: $101,536\n",
318
+ "2. Ethereum (ETH) - Market Cap: $411,695,018,306 - Price: $3,410.68\n",
319
+ "3. Tether (USDT) ...\n",
320
+ "\n",
321
+ "πŸ’­ Step 2: Reasoning\n",
322
+ " Thought: The CRYPTO agent has provided a list of the top 5 cryptocurrencies by market cap, which directly answers the user's query. I have all the necessary information to provide the final answer.\n",
323
+ " Action: FINISH\n",
324
+ " Justification: The information gathered from the CRYPTO agent is sufficient and complete to answer the user's question. No further actions are required.\n",
325
+ "\n",
326
+ "✨ Final Answer:\n",
327
+ "--------------------------------------------------------------------------------\n",
328
+ "Based on current market data, here are the top 5 cryptocurrencies ranked by market capitalization:\n",
329
+ "\n",
330
+ "**1. Bitcoin (BTC)**\n",
331
+ "* **Market Cap:** $2,025,171,098,463\n",
332
+ "* **Price:** $101,536.00\n",
333
+ "\n",
334
+ "**2. Ethereum (ETH)**\n",
335
+ "* **Market Cap:** $411,695,018,306\n",
336
+ "* **Price:** $3,410.68\n",
337
+ "\n",
338
+ "**3. Tether (USDT)**\n",
339
+ "* **Market Cap:** $184,005,021,987\n",
340
+ "* **Price:** $0.999813\n",
341
+ "\n",
342
+ "**4. XRP (XRP)**\n",
343
+ "* **Market Cap:** $143,300,134,340* **Price:** $2.38\n",
344
+ "\n",
345
+ "**5. BNB (BNB)**\n",
346
+ "* **Market Cap:** $131,225,068,810\n",
347
+ "* **Price:** $952.81\n",
348
+ "\n",
349
+ "**Key Insight:**\n",
350
+ "\n",
351
+ "Market capitalization is a key metric used to determine the relative size and value of a cryptocurrency. It is calculated by multiplying the current market price of a single coin by the total number of coins in circulation. As shown above, Bitcoin holds a dominant position with a market cap significantly larger than the others combined.\n",
352
+ "\n",
353
+ "*It is important to note that the cryptocurrency market is highly volatile, and these rankings, market caps, and prices are subject to change rapidly.*\n",
354
+ "--------------------------------------------------------------------------------\n",
355
+ "\n",
356
+ "================================================================================\n"
357
+ ]
358
+ },
359
+ {
360
+ "data": {
361
+ "text/plain": [
362
+ "'Based on current market data, here are the top 5 cryptocurrencies ranked by market capitalization:\\n\\n**1. Bitcoin (BTC)**\\n* **Market Cap:** $2,025,171,098,463\\n* **Price:** $101,536.00\\n\\n**2. Ethereum (ETH)**\\n* **Market Cap:** $411,695,018,306\\n* **Price:** $3,410.68\\n\\n**3. Tether (USDT)**\\n* **Market Cap:** $184,005,021,987\\n* **Price:** $0.999813\\n\\n**4. XRP (XRP)**\\n* **Market Cap:** $143,300,134,340* **Price:** $2.38\\n\\n**5. BNB (BNB)**\\n* **Market Cap:** $131,225,068,810\\n* **Price:** $952.81\\n\\n**Key Insight:**\\n\\nMarket capitalization is a key metric used to determine the relative size and value of a cryptocurrency. It is calculated by multiplying the current market price of a single coin by the total number of coins in circulation. As shown above, Bitcoin holds a dominant position with a market cap significantly larger than the others combined.\\n\\n*It is important to note that the cryptocurrency market is highly volatile, and these rankings, market caps, and prices are subject to change rapidly.*'"
363
+ ]
364
+ },
365
+ "execution_count": 6,
366
+ "metadata": {},
367
+ "output_type": "execute_result"
368
+ }
369
+ ],
370
+ "source": [
371
+ "# Test crypto query\n",
372
+ "test_streaming_chat(\"What are the top 5 cryptocurrencies by market cap?\")"
373
+ ]
374
+ },
375
+ {
376
+ "cell_type": "code",
377
+ "execution_count": 7,
378
+ "metadata": {},
379
+ "outputs": [
380
+ {
381
+ "name": "stdout",
382
+ "output_type": "stream",
383
+ "text": [
384
+ "πŸ’¬ Query: What is the current stock price of Apple (AAPL)?\n",
385
+ "\n",
386
+ "πŸ“‘ Streaming Response:\n",
387
+ "================================================================================\n",
388
+ "\n",
389
+ "πŸ’­ Step 1: Reasoning\n",
390
+ " Thought: The user is asking for the current stock price of Apple (AAPL). The `CALL_STOCK` action is the most appropriate tool for this query as it is designed to get stock market data. I will use this action to retrieve the price for the ticker \"AAPL\".\n",
391
+ " Action: STOCK\n",
392
+ " Justification: This action is specifically designed to retrieve stock market data, which is exactly what is needed to answer the user's query about Apple's stock price.\n",
393
+ "\n",
394
+ "πŸ”§ Calling Stock Agent...\n",
395
+ "\n",
396
+ "πŸ“Š Stock Agent Results:\n",
397
+ " The current stock price of Apple (AAPL) is $273.47.\n",
398
+ "\n",
399
+ "πŸ’­ Step 2: Reasoning\n",
400
+ " Thought: I have successfully retrieved the current stock price of Apple (AAPL) from the STOCK agent. This information directly answers the user's query. Therefore, I have sufficient information and can provide the final answer.\n",
401
+ " Action: FINISH\n",
402
+ " Justification: The STOCK agent has provided the exact information requested by the user. No further actions are necessary.\n",
403
+ "\n",
404
+ "✨ Final Answer:\n",
405
+ "--------------------------------------------------------------------------------\n",
406
+ "Based on the real-time data retrieved, the current stock price of Apple Inc. (AAPL) is **$273.47**.\n",
407
+ "\n",
408
+ "Please be aware that this price reflects the market value at the time the query was made and is subject to constant fluctuation based on market activity.\n",
409
+ "--------------------------------------------------------------------------------\n",
410
+ "\n",
411
+ "================================================================================\n"
412
+ ]
413
+ },
414
+ {
415
+ "data": {
416
+ "text/plain": [
417
+ "'Based on the real-time data retrieved, the current stock price of Apple Inc. (AAPL) is **$273.47**.\\n\\nPlease be aware that this price reflects the market value at the time the query was made and is subject to constant fluctuation based on market activity.'"
418
+ ]
419
+ },
420
+ "execution_count": 7,
421
+ "metadata": {},
422
+ "output_type": "execute_result"
423
+ }
424
+ ],
425
+ "source": [
426
+ "# Test stock query\n",
427
+ "test_streaming_chat(\"What is the current stock price of Apple (AAPL)?\")"
428
+ ]
429
+ },
430
+ {
431
+ "cell_type": "code",
432
+ "execution_count": 7,
433
+ "metadata": {},
434
+ "outputs": [
435
+ {
436
+ "name": "stdout",
437
+ "output_type": "stream",
438
+ "text": [
439
+ "πŸ’¬ Query: Search for the latest AI developments in 2025\n",
440
+ "\n",
441
+ "πŸ“‘ Streaming Response:\n",
442
+ "================================================================================\n",
443
+ "\n",
444
+ "πŸ’­ Step 1: Reasoning\n",
445
+ " Thought: The user is asking about future AI developments in 2025. This is a general knowledge and predictive query. I have no information gathered yet. The best tool to find information about current events, news, and future trends is the web search tool. The other tools (CRYPTO, STOCK, FINANCE_TRACKER, RAG) are not relevant to this topic. Therefore, I should use CALL_SEARCH to find the requested information.\n",
446
+ " Action: SEARCH\n",
447
+ " Justification: This action is the most appropriate for finding information on future technological trends and developments like those anticipated for AI in 2025.\n",
448
+ "\n",
449
+ "πŸ”§ Calling Search Agent...\n",
450
+ "\n",
451
+ "πŸ“Š Search Agent Results:\n",
452
+ " I couldn't find any results for \"latest AI developments in 2025.\" Since 2025 is in the future, there are no \"developments\" yet. I can search for predictions or trends for AI in 2025 if you'd like.\n",
453
+ "\n",
454
+ "πŸ’­ Step 2: Reasoning\n",
455
+ " Thought: The initial search for \"latest AI developments in 2025\" failed because 2025 is in the future. The SEARCH agent correctly pointed this out and suggested a better query: searching for \"predictions or trends for AI in 2025\". To answer the user's underlying question, I need to follow this suggestion and perform a new search with the revised query. This will gather the predictive information the user is likely looking for.\n",
456
+ " Action: SEARCH\n",
457
+ " Justification: The first search failed because the query was flawed. I will now use the refined query \"AI predictions and trends for 2025\" as suggested by the search agent to gather relevant information about what experts expect to see in the field of AI in the near future.\n",
458
+ "\n",
459
+ "πŸ”§ Calling Search Agent...\n",
460
+ "\n",
461
+ "πŸ“Š Search Agent Results:\n",
462
+ " I couldn't find any results for \"AI predictions and trends for 2025.\" This might be due to a temporary issue with the search engine or the way the query was phrased. I will try a more general search f...\n",
463
+ "\n",
464
+ "πŸ’­ Step 3: Reasoning\n",
465
+ " Thought: I have attempted two searches so far, and both have failed to return results. The first search was too specific about \"developments\" in the future. The second, more reasonable search for \"predictions and trends for AI in 2025\" also failed, which the agent noted might be a temporary issue. The agent suggested a broader search for \"future of AI\". This is a logical next step to gather some relevant information, as it's a broader topic that is more likely to yield results which can then be analyzed for trends relevant to 2025. I still have no information to answer the user's query, so another search is necessary.\n",
466
+ " Action: SEARCH\n",
467
+ " Justification: My previous two search attempts failed. The search agent suggested a broader query, \"future of AI,\" which is a logical next step to gather relevant information to answer the user's question about AI in 2025. I need to execute this search to obtain the necessary information.\n",
468
+ "\n",
469
+ "πŸ”§ Calling Search Agent...\n",
470
+ "\n",
471
+ "πŸ“Š Search Agent Results:\n",
472
+ " I am sorry, but I was unable to find any search results for \"latest AI developments in 2025\" or related queries like \"AI predictions and trends for 2025\" and \"future of AI.\" It seems there might be an...\n",
473
+ "\n",
474
+ "πŸ’­ Step 4: Reasoning\n",
475
+ " Thought: I have attempted to answer the user's query by using the `CALL_SEARCH` agent three times with progressively broader queries (\"latest AI developments in 2025\", \"AI predictions and trends for 2025\", and \"future of AI\"). Each attempt has failed, and the agent has explicitly stated that there might be an issue with the search tool itself. Since the only available tool capable of answering this query is not functioning, I cannot gather the necessary information. Further attempts to use the search tool are unlikely to succeed. Therefore, I must conclude the process and inform the user about the inability to retrieve the information due to a technical issue.\n",
476
+ " Action: FINISH\n",
477
+ " Justification: The `CALL_SEARCH` agent, which is the only relevant tool for this query, has failed multiple times and indicated a potential technical problem. I have exhausted all reasonable attempts to gather the information and cannot proceed further. The best course of action is to finish and communicate the issue to the user.\n",
478
+ "\n",
479
+ "✨ Final Answer:\n",
480
+ "--------------------------------------------------------------------------------\n",
481
+ "I was unable to find information on the latest AI developments for 2025.\n",
482
+ "\n",
483
+ "My attempt to answer your query involved a multi-step search process. Initially, I searched for \"latest AI developments in 2025,\" but since 2025 is in the future, no \"developments\" have occurred yet. I then broadened my search to look for \"AI predictions and trends for 2025\" and subsequently the \"future of AI.\"\n",
484
+ "\n",
485
+ "Unfortunately, all of these searches failed to return any results. This indicates a potential temporary issue with the search tool or that information specifically categorized for 2025 is not widely available at this moment.\n",
486
+ "\n",
487
+ "I recommend trying your query again later, as the issue with the search tool may be resolved.\n",
488
+ "--------------------------------------------------------------------------------\n",
489
+ "\n",
490
+ "================================================================================\n"
491
+ ]
492
+ },
493
+ {
494
+ "data": {
495
+ "text/plain": [
496
+ "'I was unable to find information on the latest AI developments for 2025.\\n\\nMy attempt to answer your query involved a multi-step search process. Initially, I searched for \"latest AI developments in 2025,\" but since 2025 is in the future, no \"developments\" have occurred yet. I then broadened my search to look for \"AI predictions and trends for 2025\" and subsequently the \"future of AI.\"\\n\\nUnfortunately, all of these searches failed to return any results. This indicates a potential temporary issue with the search tool or that information specifically categorized for 2025 is not widely available at this moment.\\n\\nI recommend trying your query again later, as the issue with the search tool may be resolved.'"
497
+ ]
498
+ },
499
+ "execution_count": 7,
500
+ "metadata": {},
501
+ "output_type": "execute_result"
502
+ }
503
+ ],
504
+ "source": [
505
+ "# Test web search query\n",
506
+ "test_streaming_chat(\"Search for the latest AI developments in 2025\")"
507
+ ]
508
+ },
509
+ {
510
+ "cell_type": "code",
511
+ "execution_count": null,
512
+ "metadata": {},
513
+ "outputs": [],
514
+ "source": [
515
+ "# Test finance tracker - adding a transaction\n",
516
+ "test_streaming_chat(\"Add 10 shares of AAPL that I bought at $150\")"
517
+ ]
518
+ },
519
+ {
520
+ "cell_type": "code",
521
+ "execution_count": 8,
522
+ "metadata": {},
523
+ "outputs": [
524
+ {
525
+ "name": "stdout",
526
+ "output_type": "stream",
527
+ "text": [
528
+ "πŸ’¬ Query: What's Bitcoin's price and what's the latest news about cryptocurrency?\n",
529
+ "\n",
530
+ "πŸ“‘ Streaming Response:\n",
531
+ "================================================================================\n",
532
+ "\n",
533
+ "πŸ’­ Step 1: Reasoning\n",
534
+ " Thought: The user is asking for two pieces of information: the current price of Bitcoin and the latest news about cryptocurrency. I need to address both parts of the query. The `CALL_CRYPTO` action is the most direct way to get the price of Bitcoin. I will start with that.\n",
535
+ " Action: CRYPTO\n",
536
+ " Justification: This action is specifically designed to retrieve cryptocurrency market data and prices, which directly answers the first part of the user's query about Bitcoin's price.\n",
537
+ "\n",
538
+ "πŸ”§ Calling Crypto Agent...\n",
539
+ "\n",
540
+ "πŸ“Š Crypto Agent Results:\n",
541
+ " I can only fetch the price of Bitcoin at the moment. Bitcoin's current price is $101,568. I cannot get the latest news about cryptocurrency.\n",
542
+ "\n",
543
+ "πŸ’­ Step 2: Reasoning\n",
544
+ " Thought: I have successfully obtained the price of Bitcoin from the CRYPTO agent. However, the user's query also asks for the latest news about cryptocurrency, which the CRYPTO agent was unable to provide. To fulfill the second part of the request, I need to use an agent that can find current news. The SEARCH agent is the most suitable tool for this purpose.\n",
545
+ " Action: SEARCH\n",
546
+ " Justification: This action will allow me to find the latest news about cryptocurrency, which is the remaining piece of information needed to fully answer the user's query.\n",
547
+ "\n",
548
+ "πŸ”§ Calling Search Agent...\n",
549
+ "\n",
550
+ "πŸ“Š Search Agent Results:\n",
551
+ " Bitcoin's current price is $101,568.\n",
552
+ "\n",
553
+ "For the latest cryptocurrency news, you can check out these sources:\n",
554
+ "\n",
555
+ "* **Crypto News:** https://cryptonews.com/\n",
556
+ "* **Forbes Digital Assets:** https://www.forb...\n",
557
+ "\n",
558
+ "πŸ’­ Step 3: Reasoning\n",
559
+ " Thought: I have successfully gathered the necessary information to answer the user's query. The CRYPTO and SEARCH agents both provided Bitcoin's current price, which is consistent. The SEARCH agent also provided a list of reliable sources for the latest cryptocurrency news. I do not need to call any more agents as I have a complete answer. I can now synthesize this information and provide the final response.\n",
560
+ " Action: FINISH\n",
561
+ " Justification: I have the price of Bitcoin and a list of sources for the latest cryptocurrency news. This is sufficient to fully answer the user's query.\n",
562
+ "\n",
563
+ "✨ Final Answer:\n",
564
+ "--------------------------------------------------------------------------------\n",
565
+ "The current price of Bitcoin is **$101,568**.\n",
566
+ "\n",
567
+ "For the latest news and in-depth coverage of the cryptocurrency market, you can refer to these reputable sources:\n",
568
+ "\n",
569
+ "* **CoinDesk:** [https://www.coindesk.com/](https://www.coindesk.com/)\n",
570
+ "* **Cointelegraph:** [https://cointelegraph.com/](https://cointelegraph.com/)\n",
571
+ "* **Crypto News:** [https://cryptonews.com/](https://cryptonews.com/)\n",
572
+ "* **Forbes Digital Assets:** [https://www.forbes.com/digital-assets/news/](https://www.forbes.com/digital-assets/news/)\n",
573
+ "* **Reuters Cryptocurrency:** [https://www.reuters.com/markets/cryptocurrency/](https://www.reuters.com/markets/cryptocurrency/)\n",
574
+ "--------------------------------------------------------------------------------\n",
575
+ "\n",
576
+ "================================================================================\n"
577
+ ]
578
+ },
579
+ {
580
+ "data": {
581
+ "text/plain": [
582
+ "'The current price of Bitcoin is **$101,568**.\\n\\nFor the latest news and in-depth coverage of the cryptocurrency market, you can refer to these reputable sources:\\n\\n* **CoinDesk:** [https://www.coindesk.com/](https://www.coindesk.com/)\\n* **Cointelegraph:** [https://cointelegraph.com/](https://cointelegraph.com/)\\n* **Crypto News:** [https://cryptonews.com/](https://cryptonews.com/)\\n* **Forbes Digital Assets:** [https://www.forbes.com/digital-assets/news/](https://www.forbes.com/digital-assets/news/)\\n* **Reuters Cryptocurrency:** [https://www.reuters.com/markets/cryptocurrency/](https://www.reuters.com/markets/cryptocurrency/)'"
583
+ ]
584
+ },
585
+ "execution_count": 8,
586
+ "metadata": {},
587
+ "output_type": "execute_result"
588
+ }
589
+ ],
590
+ "source": [
591
+ "# Test multi-agent query (requires multiple agents)\n",
592
+ "test_streaming_chat(\"What's Bitcoin's price and what's the latest news about cryptocurrency?\")"
593
+ ]
594
+ },
595
+ {
596
+ "cell_type": "markdown",
597
+ "metadata": {},
598
+ "source": [
599
+ "## 4. WebSocket Chat\n",
600
+ "\n",
601
+ "Test the WebSocket endpoint for real-time bidirectional communication."
602
+ ]
603
+ },
604
+ {
605
+ "cell_type": "code",
606
+ "execution_count": 9,
607
+ "metadata": {},
608
+ "outputs": [
609
+ {
610
+ "name": "stdout",
611
+ "output_type": "stream",
612
+ "text": [
613
+ "πŸ’¬ WebSocket Query: What is Ethereum's current price?\n",
614
+ "\n",
615
+ "πŸ“‘ WebSocket Response:\n",
616
+ "================================================================================\n",
617
+ "\n",
618
+ "πŸ’­ Step 1: Reasoning\n",
619
+ " Thought: The user is asking for the current price of Ethereum. Ethereum is a cryptocurrency. The `CALL_CRYPTO` action is specifically designed to get cryptocurrency market data and prices. Therefore, this is the most direct and appropriate action to take.\n",
620
+ " Action: CRYPTO\n",
621
+ "\n",
622
+ "πŸ”§ Calling Crypto Agent...\n",
623
+ "\n",
624
+ "πŸ“Š Crypto Agent Results\n",
625
+ "\n",
626
+ "πŸ’­ Step 2: Reasoning\n",
627
+ " Thought: The user asked for the current price of Ethereum. The CRYPTO agent has already provided this information: \"The current price of Ethereum is $3423.75.\" I have a direct and complete answer to the user's query. Therefore, I can now finish.\n",
628
+ " Action: FINISH\n",
629
+ "\n",
630
+ "✨ Final Answer:\n",
631
+ "--------------------------------------------------------------------------------\n",
632
+ "Based on the latest market data, the current price of Ethereum (ETH) is **$3,423.75**.\n",
633
+ "\n",
634
+ "It is important to note that cryptocurrency prices are highly volatile and can change rapidly. This price reflects a snapshot at the time the data was retrieved.\n",
635
+ "--------------------------------------------------------------------------------\n",
636
+ "\n",
637
+ "================================================================================\n"
638
+ ]
639
+ }
640
+ ],
641
+ "source": [
642
+ "async def test_websocket_chat(message, history=None):\n",
643
+ " \"\"\"Test WebSocket chat endpoint.\"\"\"\n",
644
+ " if history is None:\n",
645
+ " history = []\n",
646
+ " \n",
647
+ " ws_url = BASE_URL.replace('http://', 'ws://') + '/ws/v1/chat'\n",
648
+ " \n",
649
+ " print(f\"πŸ’¬ WebSocket Query: {message}\")\n",
650
+ " print(\"\\nπŸ“‘ WebSocket Response:\")\n",
651
+ " print(\"=\" * 80)\n",
652
+ " \n",
653
+ " try:\n",
654
+ " async with websockets.connect(ws_url) as websocket:\n",
655
+ " # Send message\n",
656
+ " await websocket.send(json.dumps({\n",
657
+ " \"message\": message,\n",
658
+ " \"history\": history\n",
659
+ " }))\n",
660
+ " \n",
661
+ " final_answer = \"\"\n",
662
+ " \n",
663
+ " # Receive streaming updates\n",
664
+ " while True:\n",
665
+ " try:\n",
666
+ " response = await asyncio.wait_for(websocket.recv(), timeout=60.0)\n",
667
+ " event = json.loads(response)\n",
668
+ " event_type = event.get('type', 'unknown')\n",
669
+ " \n",
670
+ " if event_type == 'thinking':\n",
671
+ " print(f\"\\nπŸ’­ Step {event.get('step', '?')}: Reasoning\")\n",
672
+ " print(f\" Thought: {event.get('thought', 'N/A')}\")\n",
673
+ " print(f\" Action: {event.get('action', 'N/A').upper()}\")\n",
674
+ " \n",
675
+ " elif event_type == 'action':\n",
676
+ " agent = event.get('agent', 'unknown')\n",
677
+ " print(f\"\\nπŸ”§ Calling {agent.title()} Agent...\")\n",
678
+ " \n",
679
+ " elif event_type == 'observation':\n",
680
+ " agent = event.get('agent', 'unknown')\n",
681
+ " summary = event.get('summary', '')\n",
682
+ " print(f\"\\nπŸ“Š {agent.title()} Agent Results\")\n",
683
+ " \n",
684
+ " elif event_type == 'final_start':\n",
685
+ " print(\"\\n✨ Final Answer:\")\n",
686
+ " print(\"-\" * 80)\n",
687
+ " \n",
688
+ " elif event_type == 'final_token':\n",
689
+ " final_answer = event.get('accumulated', '')\n",
690
+ " \n",
691
+ " elif event_type == 'final_complete':\n",
692
+ " print(final_answer)\n",
693
+ " print(\"-\" * 80)\n",
694
+ " break\n",
695
+ " \n",
696
+ " elif event_type == 'error':\n",
697
+ " print(f\"\\n❌ Error: {event.get('error', 'Unknown error')}\")\n",
698
+ " break\n",
699
+ " \n",
700
+ " except asyncio.TimeoutError:\n",
701
+ " print(\"\\n⏱️ Timeout waiting for response\")\n",
702
+ " break\n",
703
+ " \n",
704
+ " print(\"\\n\" + \"=\" * 80)\n",
705
+ " return final_answer\n",
706
+ " \n",
707
+ " except Exception as e:\n",
708
+ " print(f\"\\n❌ Error: {e}\")\n",
709
+ " return None\n",
710
+ "\n",
711
+ "# Test WebSocket\n",
712
+ "ws_result = await test_websocket_chat(\"What is Ethereum's current price?\")"
713
+ ]
714
+ },
715
+ {
716
+ "cell_type": "markdown",
717
+ "metadata": {},
718
+ "source": [
719
+ "## 5. Document Upload\n",
720
+ "\n",
721
+ "Test uploading a document to the RAG agent."
722
+ ]
723
+ },
724
+ {
725
+ "cell_type": "code",
726
+ "execution_count": null,
727
+ "metadata": {},
728
+ "outputs": [],
729
+ "source": [
730
+ "def test_document_upload(file_path):\n",
731
+ " \"\"\"Test document upload endpoint.\"\"\"\n",
732
+ " try:\n",
733
+ " if not Path(file_path).exists():\n",
734
+ " print(f\"❌ File not found: {file_path}\")\n",
735
+ " return None\n",
736
+ " \n",
737
+ " print(f\"πŸ“€ Uploading: {file_path}\")\n",
738
+ " \n",
739
+ " with open(file_path, 'rb') as f:\n",
740
+ " files = {'file': (Path(file_path).name, f)}\n",
741
+ " response = requests.post(\n",
742
+ " f\"{BASE_URL}/api/v1/documents/upload\",\n",
743
+ " files=files\n",
744
+ " )\n",
745
+ " \n",
746
+ " response.raise_for_status()\n",
747
+ " data = response.json()\n",
748
+ " \n",
749
+ " print(\"\\nβœ… Upload Response:\")\n",
750
+ " print(f\" Success: {data['success']}\")\n",
751
+ " print(f\" Message: {data['message']}\")\n",
752
+ " \n",
753
+ " if data.get('details'):\n",
754
+ " print(\"\\n Details:\")\n",
755
+ " for key, value in data['details'].items():\n",
756
+ " print(f\" {key}: {value}\")\n",
757
+ " \n",
758
+ " return data\n",
759
+ " \n",
760
+ " except requests.exceptions.HTTPError as e:\n",
761
+ " print(f\"\\n❌ HTTP Error: {e}\")\n",
762
+ " print(f\" Response: {e.response.text}\")\n",
763
+ " return None\n",
764
+ " except Exception as e:\n",
765
+ " print(f\"\\n❌ Error: {e}\")\n",
766
+ " return None\n",
767
+ "\n",
768
+ "# Example: Upload a test file (update path to your actual file)\n",
769
+ "test_document_upload(\"PATH_TO_YOUR_DOCUEMNT\")\n",
770
+ "\n",
771
+ "print(\"⚠️ To test document upload, uncomment the line above and provide a valid file path\")"
772
+ ]
773
+ },
774
+ {
775
+ "cell_type": "code",
776
+ "execution_count": 8,
777
+ "metadata": {},
778
+ "outputs": [
779
+ {
780
+ "name": "stdout",
781
+ "output_type": "stream",
782
+ "text": [
783
+ "βœ… Created test file: test_document.txt\n",
784
+ "πŸ“€ Uploading: test_document.txt\n",
785
+ "\n",
786
+ "βœ… Upload Response:\n",
787
+ " Success: True\n",
788
+ " Message: Document uploaded successfully\n",
789
+ "\n",
790
+ " Details:\n",
791
+ " filename: tmphdduvt25.txt\n",
792
+ " file_type: .txt\n",
793
+ " chunks_added: 1\n",
794
+ " total_documents: 229\n"
795
+ ]
796
+ }
797
+ ],
798
+ "source": [
799
+ "# Create a test text file and upload it\n",
800
+ "test_file_path = \"test_document.txt\"\n",
801
+ "\n",
802
+ "with open(test_file_path, 'w') as f:\n",
803
+ " f.write(\"\"\"\n",
804
+ " Test Document for RAG Agent\n",
805
+ " \n",
806
+ " This is a sample document for testing the RAG agent's document upload functionality.\n",
807
+ " \n",
808
+ " Key Information:\n",
809
+ " - The multi-agent system uses Google Gemini 2.5 Pro\n",
810
+ " - It implements a ReAct (Reasoning + Acting) pattern\n",
811
+ " - Available agents: Crypto, Stock, RAG, Search, Finance Tracker\n",
812
+ " - Each agent uses specialized MCP servers for their domain\n",
813
+ " \n",
814
+ " The system can handle co plex queries that require multiple agents working together.\n",
815
+ " \"\"\")\n",
816
+ "\n",
817
+ "print(f\"βœ… Created test file: {test_file_path}\")\n",
818
+ "\n",
819
+ "# Upload the test file\n",
820
+ "upload_result = test_document_upload(test_file_path)"
821
+ ]
822
+ },
823
+ {
824
+ "cell_type": "code",
825
+ "execution_count": 9,
826
+ "metadata": {},
827
+ "outputs": [
828
+ {
829
+ "name": "stdout",
830
+ "output_type": "stream",
831
+ "text": [
832
+ "πŸ’¬ Query: What applications of AI agents are described in my collection?\n",
833
+ "\n",
834
+ "πŸ“‘ Streaming Response:\n",
835
+ "================================================================================\n",
836
+ "\n",
837
+ "πŸ’­ Step 1: Reasoning\n",
838
+ " Thought: The user is asking about applications of AI agents described in \"my collection\". This indicates that the information should be retrieved from the user's uploaded documents. The `CALL_RAG` tool is designed for this purpose.\n",
839
+ " Action: RAG\n",
840
+ " Justification: The query specifically asks for information from \"my collection,\" which implies searching the user's uploaded documents. `CALL_RAG` is the appropriate tool to retrieve information from these documents.\n",
841
+ "\n",
842
+ "πŸ”§ Calling Rag Agent...\n",
843
+ "\n",
844
+ "πŸ“Š Rag Agent Results:\n",
845
+ " Based on the provided document excerpts, there is no information available regarding the current status of any project. The documents discuss applications of AI agents, challenges in their integration...\n",
846
+ "\n",
847
+ "πŸ’­ Step 2: Reasoning\n",
848
+ " Thought: The user asked for \"What applications of AI agents are described in my collection?\". I previously called `CALL_RAG`, and the observation stated: \"The documents discuss applications of AI agents, challenges in their integration, and various machine learning models and their use cases.\" This confirms that the information about AI agent applications *is* present in the documents. However, the previous `RAG` call did not actually list these applications. I need to call `CALL_RAG` again, but this time with a more specific prompt to extract and list the applications of AI agents mentioned in the documents.\n",
849
+ " Action: RAG\n",
850
+ " Justification: The previous `CALL_RAG` observation confirmed that the documents contain information about \"applications of AI agents\". I need to call `CALL_RAG` again to specifically extract and list these applications, as the previous call did not provide them.\n",
851
+ "\n",
852
+ "πŸ”§ Calling Rag Agent...\n",
853
+ "\n",
854
+ "πŸ“Š Rag Agent Results:\n",
855
+ " Based on the provided document excerpts, there is no information available regarding the benefits of the Mediterranean diet for brain health. The documents discuss applications of AI agents, challenge...\n",
856
+ "\n",
857
+ "πŸ’­ Step 3: Reasoning\n",
858
+ " Thought: The user is asking for a list of AI agent applications from their collection. I have called `CALL_RAG` twice. The first call indicated that the documents discuss \"applications of AI agents\". The second call was problematic, mentioning \"Mediterranean diet\" while still stating that \"The documents discuss applications of AI agents\". This indicates that the RAG tool is acknowledging the presence of the information but failing to extract and list the specific applications. I need to call `CALL_RAG` again, but this time, I must ensure the prompt is very explicit about *listing* the applications to overcome the previous failures.\n",
859
+ " Action: RAG\n",
860
+ " Justification: The `CALL_RAG` tool is the only way to access information from the user's uploaded documents (\"my collection\"). Despite previous attempts, the tool has not yet provided the actual list of AI agent applications, even though it confirms the information is present. I need to try again with a more precise prompt to extract and list these applications.\n",
861
+ "\n",
862
+ "πŸ”§ Calling Rag Agent...\n",
863
+ "\n",
864
+ "πŸ“Š Rag Agent Results:\n",
865
+ " Based on the provided document excerpts, the following applications of AI agents are described:\n",
866
+ "\n",
867
+ "For **Marketing Professionals**:\n",
868
+ "* Content generation agents\n",
869
+ "* Market research agents\n",
870
+ "* Performan...\n",
871
+ "\n",
872
+ "πŸ’­ Step 4: Reasoning\n",
873
+ " Thought: I have successfully retrieved the applications of AI agents described in the user's collection using `CALL_RAG`. The information gathered directly answers the user's query.\n",
874
+ " Action: FINISH\n",
875
+ " Justification: I have sufficient information to answer the user's query about the applications of AI agents described in their collection.\n",
876
+ "\n",
877
+ "✨ Final Answer:\n",
878
+ "--------------------------------------------------------------------------------\n",
879
+ "Based on the documents in your collection, AI agents are described with specific applications for both marketing and sales professionals:\n",
880
+ "\n",
881
+ "**For Marketing Professionals:**\n",
882
+ "* **Content generation agents:** To assist in creating various forms of marketing content.\n",
883
+ "* **Market research agents:** For conducting research to understand market trends and opportunities.\n",
884
+ "* **Performance analytics agents:** To analyze the effectiveness of marketing campaigns and strategies.\n",
885
+ "* **Audience insight agents:** For gathering and interpreting data about target audiences.\n",
886
+ "\n",
887
+ "**For Sales Professionals:**\n",
888
+ "* **Prospect research agents:** To identify and gather information on potential clients.\n",
889
+ "* **Outreach customization agents:** For tailoring communication to individual prospects.\n",
890
+ "* **Meeting preparation agents:** To assist in preparing for sales meetings.\n",
891
+ "* **Follow-up management agents:** For organizing and executing post-meeting follow-up tasks.\n",
892
+ "--------------------------------------------------------------------------------\n",
893
+ "\n",
894
+ "================================================================================\n"
895
+ ]
896
+ }
897
+ ],
898
+ "source": [
899
+ "# Now query the uploaded document\n",
900
+ "if upload_result and upload_result.get('success'):\n",
901
+ " test_streaming_chat(\"What applications of AI agents are described in my collection?\")"
902
+ ]
903
+ },
904
+ {
905
+ "cell_type": "markdown",
906
+ "metadata": {},
907
+ "source": [
908
+ "## 6. Conversation History\n",
909
+ "\n",
910
+ "Test chat with conversation history."
911
+ ]
912
+ },
913
+ {
914
+ "cell_type": "code",
915
+ "execution_count": 6,
916
+ "metadata": {},
917
+ "outputs": [
918
+ {
919
+ "name": "stdout",
920
+ "output_type": "stream",
921
+ "text": [
922
+ "\n",
923
+ "====================================================================================================\n",
924
+ "MESSAGE 1\n",
925
+ "====================================================================================================\n",
926
+ "πŸ’¬ Query: What is Bitcoin?\n",
927
+ "\n",
928
+ "πŸ“‘ Streaming Response:\n",
929
+ "================================================================================\n",
930
+ "\n",
931
+ "πŸ’­ Step 1: Reasoning\n",
932
+ " Thought: The user is asking for a definition of \"Bitcoin\". This is a general knowledge question. I do not have any information yet. The best tool for answering general knowledge questions is `CALL_SEARCH`. The other tools are more specialized: `CALL_CRYPTO` is for market data, not definitions; `CALL_STOCK` and `CALL_FINANCE_TRACKER` are for traditional finance; and `CALL_RAG` requires pre-existing documents. Therefore, I should use `CALL_SEARCH` to find out what Bitcoin is.\n",
933
+ " Action: SEARCH\n",
934
+ " Justification: The user's query \"What is Bitcoin?\" is a general knowledge question. The `CALL_SEARCH` action is the most appropriate tool to find a definition and explanation from the web.\n",
935
+ "\n",
936
+ "πŸ”§ Calling Search Agent...\n",
937
+ "\n",
938
+ "πŸ“Š Search Agent Results:\n",
939
+ " Bitcoin is a decentralized digital currency that allows for direct transactions between users without the need for an intermediary like a bank. It was invented in 2008 by an unknown entity or group us...\n",
940
+ "\n",
941
+ "πŸ’­ Step 2: Reasoning\n",
942
+ " Thought: I have successfully used the `CALL_SEARCH` agent to get a clear and comprehensive definition of Bitcoin. The information gathered explains what it is, who created it, and the technology behind it. This is sufficient to answer the user's query. I do not need to call any other agents. I can now provide the final answer.\n",
943
+ " Action: FINISH\n",
944
+ " Justification: I have a complete answer to the user's question \"What is Bitcoin?\" from the previous `CALL_SEARCH` action. No further information is required.\n",
945
+ "\n",
946
+ "✨ Final Answer:\n",
947
+ "--------------------------------------------------------------------------------\n",
948
+ "Based on the information gathered, here is a comprehensive answer to your query.\n",
949
+ "\n",
950
+ "### What is Bitcoin?\n",
951
+ "\n",
952
+ "Bitcoin is a decentralized digital currency that enables direct, peer-to-peer transactions between users without the need for an intermediary like a bank or financial institution. It was the first cryptocurrency to be created and remains the most well-known.\n",
953
+ "\n",
954
+ "#### Key Features:* **Decentralized:** Unlike traditional currencies issued by governments (like the U.S. Dollar), Bitcoin is not controlled by any single entity. This means it is resistant to censorship and manipulation by a central authority.\n",
955
+ "* **Digital Nature:** Bitcoin exists only in the digital realm. It is not a physical coin or bill but a balance associated with a digital address.\n",
956
+ "* **Peer-to-Peer Technology:** Transactions are sent directly from one user to another across a global network of computers.\n",
957
+ "* **Blockchain Technology:** All transactions are recorded on a public, distributed ledger called the blockchain. This technology ensures transparency and security, as every transaction is permanent and visible to everyone on the network.\n",
958
+ "* **Cryptography:** Advanced cryptographic techniques are used to secure the network, verify transactions, and control the creation of new bitcoins.\n",
959
+ "\n",
960
+ "#### Origins:\n",
961
+ "\n",
962
+ "Bitcoin was invented in 2008 by an unknown person or group of people using the pseudonym **Satoshi Nakamoto**. The network went live in 2009, marking the beginning of its use as a currency.\n",
963
+ "\n",
964
+ "***\n",
965
+ "*Sources: Forbes Advisor, Investopedia, Bitcoin.org, Analytics Insight, Wikipedia, Britannica Money, Crypto.com, Cointelegraph*\n",
966
+ "--------------------------------------------------------------------------------\n",
967
+ "\n",
968
+ "================================================================================\n",
969
+ "\n",
970
+ "====================================================================================================\n",
971
+ "MESSAGE 2 (with context from previous message)\n",
972
+ "====================================================================================================\n",
973
+ "πŸ’¬ Query: What is its current price?\n",
974
+ "\n",
975
+ "πŸ“‘ Streaming Response:\n",
976
+ "================================================================================\n",
977
+ "\n",
978
+ "πŸ’­ Step 1: Reasoning\n",
979
+ " Thought: The user is asking for the current price of \"it\". Based on the previous turn in the conversation history, \"it\" refers to Bitcoin. To answer this question, I need to get the current market price of Bitcoin. The `CALL_CRYPTO` tool is specifically designed to retrieve cryptocurrency prices.\n",
980
+ " Action: CRYPTO\n",
981
+ " Justification: This action will provide the real-time price of Bitcoin, which is the specific information needed to answer the user's query.\n",
982
+ "\n",
983
+ "πŸ”§ Calling Crypto Agent...\n",
984
+ "\n",
985
+ "πŸ“Š Crypto Agent Results:\n",
986
+ " The current price of Bitcoin is $98,857. Over the last 24 hours, its price has changed by -2.74%, and its 24-hour trading volume is $98,820,417,484.34. The market cap for Bitcoin is $1,970,383,895,456...\n",
987
+ "\n",
988
+ "πŸ’­ Step 2: Reasoning\n",
989
+ " Thought: I have successfully retrieved the current price of Bitcoin using the `CALL_CRYPTO` agent. The information gathered directly answers the user's query. Therefore, I have sufficient information and should now provide the final answer.\n",
990
+ " Action: FINISH\n",
991
+ " Justification: The `CALL_CRYPTO` agent provided the current price of Bitcoin, which is exactly what the user asked for. No further information is needed.\n",
992
+ "\n",
993
+ "✨ Final Answer:\n",
994
+ "--------------------------------------------------------------------------------\n",
995
+ "Based on real-time market data, the current price of Bitcoin is **$98,857**.\n",
996
+ "\n",
997
+ "Here is some additional market information:\n",
998
+ "\n",
999
+ "* **24-Hour Price Change:** -2.74%\n",
1000
+ "* **Market Cap:** $1,970,383,895,456.65* **24-Hour Trading Volume:** $98,820,417,484.34\n",
1001
+ "\n",
1002
+ "***\n",
1003
+ "*Please note: Cryptocurrency prices are highly volatile and can change rapidly. This information reflects the market at the time this query was made.*\n",
1004
+ "--------------------------------------------------------------------------------\n",
1005
+ "\n",
1006
+ "================================================================================\n"
1007
+ ]
1008
+ }
1009
+ ],
1010
+ "source": [
1011
+ "# Start a conversation\n",
1012
+ "history = []\n",
1013
+ "\n",
1014
+ "# First message\n",
1015
+ "print(\"\\n\" + \"=\"*100)\n",
1016
+ "print(\"MESSAGE 1\")\n",
1017
+ "print(\"=\"*100)\n",
1018
+ "result1 = test_streaming_chat(\"What is Bitcoin?\", history=history)\n",
1019
+ "history.append({\"role\": \"user\", \"content\": \"What is Bitcoin?\"})\n",
1020
+ "history.append({\"role\": \"assistant\", \"content\": result1})\n",
1021
+ "\n",
1022
+ "time.sleep(2)\n",
1023
+ "\n",
1024
+ "# Follow-up question (with context)\n",
1025
+ "print(\"\\n\" + \"=\"*100)\n",
1026
+ "print(\"MESSAGE 2 (with context from previous message)\")\n",
1027
+ "print(\"=\"*100)\n",
1028
+ "result2 = test_streaming_chat(\"What is its current price?\", history=history)\n",
1029
+ "history.append({\"role\": \"user\", \"content\": \"What is its current price?\"})\n",
1030
+ "history.append({\"role\": \"assistant\", \"content\": result2})"
1031
+ ]
1032
+ },
1033
+ {
1034
+ "cell_type": "markdown",
1035
+ "metadata": {},
1036
+ "source": [
1037
+ "## 7. Performance Testing\n",
1038
+ "\n",
1039
+ "Test response times and measure performance."
1040
+ ]
1041
+ },
1042
+ {
1043
+ "cell_type": "code",
1044
+ "execution_count": 26,
1045
+ "metadata": {},
1046
+ "outputs": [
1047
+ {
1048
+ "name": "stdout",
1049
+ "output_type": "stream",
1050
+ "text": [
1051
+ "\n",
1052
+ "⏱️ Performance Metrics for: 'What is Bitcoin price?'\n",
1053
+ " Time to first token: 14.65s\n",
1054
+ " Total response time: 36.27s\n"
1055
+ ]
1056
+ },
1057
+ {
1058
+ "data": {
1059
+ "text/plain": [
1060
+ "{'first_token': 14.646224975585938, 'total': 36.274803161621094}"
1061
+ ]
1062
+ },
1063
+ "execution_count": 26,
1064
+ "metadata": {},
1065
+ "output_type": "execute_result"
1066
+ }
1067
+ ],
1068
+ "source": [
1069
+ "import time\n",
1070
+ "\n",
1071
+ "def measure_response_time(query):\n",
1072
+ " \"\"\"Measure response time for a query.\"\"\"\n",
1073
+ " payload = {\"message\": query, \"history\": []}\n",
1074
+ " \n",
1075
+ " start_time = time.time()\n",
1076
+ " first_token_time = None\n",
1077
+ " \n",
1078
+ " try:\n",
1079
+ " response = requests.post(\n",
1080
+ " f\"{BASE_URL}/api/v1/chat/stream\",\n",
1081
+ " json=payload,\n",
1082
+ " headers={\"Accept\": \"text/event-stream\"},\n",
1083
+ " stream=True\n",
1084
+ " )\n",
1085
+ " \n",
1086
+ " for i, line in enumerate(response.iter_lines()):\n",
1087
+ " if i == 0 and first_token_time is None:\n",
1088
+ " first_token_time = time.time() - start_time\n",
1089
+ " if line and b'final_complete' in line:\n",
1090
+ " break\n",
1091
+ " \n",
1092
+ " total_time = time.time() - start_time\n",
1093
+ " \n",
1094
+ " print(f\"\\n⏱️ Performance Metrics for: '{query}'\")\n",
1095
+ " print(f\" Time to first token: {first_token_time:.2f}s\")\n",
1096
+ " print(f\" Total response time: {total_time:.2f}s\")\n",
1097
+ " \n",
1098
+ " return {\"first_token\": first_token_time, \"total\": total_time}\n",
1099
+ " \n",
1100
+ " except Exception as e:\n",
1101
+ " print(f\"❌ Error: {e}\")\n",
1102
+ " return None\n",
1103
+ "\n",
1104
+ "# Test with simple query\n",
1105
+ "measure_response_time(\"What is Bitcoin price?\")"
1106
+ ]
1107
+ },
1108
+ {
1109
+ "cell_type": "markdown",
1110
+ "metadata": {},
1111
+ "source": [
1112
+ "## 8. Error Handling Tests\n",
1113
+ "\n",
1114
+ "Test how the API handles various error conditions."
1115
+ ]
1116
+ },
1117
+ {
1118
+ "cell_type": "code",
1119
+ "execution_count": null,
1120
+ "metadata": {},
1121
+ "outputs": [],
1122
+ "source": [
1123
+ "# Test empty message\n",
1124
+ "print(\"Testing empty message:\")\n",
1125
+ "test_streaming_chat(\"\")"
1126
+ ]
1127
+ },
1128
+ {
1129
+ "cell_type": "code",
1130
+ "execution_count": null,
1131
+ "metadata": {},
1132
+ "outputs": [],
1133
+ "source": [
1134
+ "# Test invalid file upload\n",
1135
+ "print(\"Testing invalid file type:\")\n",
1136
+ "\n",
1137
+ "invalid_file_path = \"/tmp/test.exe\"\n",
1138
+ "with open(invalid_file_path, 'w') as f:\n",
1139
+ " f.write(\"invalid content\")\n",
1140
+ "\n",
1141
+ "test_document_upload(invalid_file_path)"
1142
+ ]
1143
+ },
1144
+ {
1145
+ "cell_type": "markdown",
1146
+ "metadata": {},
1147
+ "source": [
1148
+ "## 9. API Documentation\n",
1149
+ "\n",
1150
+ "The FastAPI server provides automatic interactive documentation.\n",
1151
+ "\n",
1152
+ "**Access the documentation at:**\n",
1153
+ "- Swagger UI: http://localhost:8000/docs\n",
1154
+ "- ReDoc: http://localhost:8000/redoc\n",
1155
+ "\n",
1156
+ "You can test all endpoints directly from the browser using the Swagger UI!"
1157
+ ]
1158
+ },
1159
+ {
1160
+ "cell_type": "code",
1161
+ "execution_count": 25,
1162
+ "metadata": {},
1163
+ "outputs": [
1164
+ {
1165
+ "name": "stdout",
1166
+ "output_type": "stream",
1167
+ "text": [
1168
+ "πŸ“š FastAPI Interactive Documentation\n",
1169
+ "\n",
1170
+ "πŸ”— Swagger UI: http://localhost:8000/docs\n",
1171
+ "πŸ”— ReDoc: http://localhost:8000/redoc\n"
1172
+ ]
1173
+ }
1174
+ ],
1175
+ "source": [
1176
+ "from IPython.display import IFrame\n",
1177
+ "\n",
1178
+ "# Display Swagger UI in notebook (if server is running)\n",
1179
+ "print(\"πŸ“š FastAPI Interactive Documentation\")\n",
1180
+ "print(f\"\\nπŸ”— Swagger UI: {BASE_URL}/docs\")\n",
1181
+ "print(f\"πŸ”— ReDoc: {BASE_URL}/redoc\")\n",
1182
+ "\n",
1183
+ "# Uncomment to embed in notebook\n",
1184
+ "# IFrame(f\"{BASE_URL}/docs\", width=1000, height=600)"
1185
+ ]
1186
+ }
1187
+ ],
1188
+ "metadata": {
1189
+ "kernelspec": {
1190
+ "display_name": "venv (3.11.5)",
1191
+ "language": "python",
1192
+ "name": "python3"
1193
+ },
1194
+ "language_info": {
1195
+ "codemirror_mode": {
1196
+ "name": "ipython",
1197
+ "version": 3
1198
+ },
1199
+ "file_extension": ".py",
1200
+ "mimetype": "text/x-python",
1201
+ "name": "python",
1202
+ "nbconvert_exporter": "python",
1203
+ "pygments_lexer": "ipython3",
1204
+ "version": "3.11.5"
1205
+ }
1206
+ },
1207
+ "nbformat": 4,
1208
+ "nbformat_minor": 4
1209
+ }
requirements.txt ADDED
Binary file (3.15 kB). View file
 
scripts/setup.sh ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ echo "======================================"
4
+ echo "Multi-Agent Crypto Assistant Setup"
5
+ echo "======================================"
6
+ echo ""
7
+
8
+ # Check Python version
9
+ python_version=$(python3 --version 2>&1 | grep -oP '\d+\.\d+')
10
+ required_version="3.9"
11
+
12
+ if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" != "$required_version" ]; then
13
+ echo "❌ Error: Python 3.9 or higher is required"
14
+ echo "Current version: $(python3 --version)"
15
+ exit 1
16
+ fi
17
+
18
+ echo "βœ… Python version check passed: $(python3 --version)"
19
+ echo ""
20
+
21
+ # Create virtual environment
22
+ echo "πŸ“¦ Creating virtual environment..."
23
+ python3 -m venv venv
24
+
25
+ if [ $? -ne 0 ]; then
26
+ echo "❌ Failed to create virtual environment"
27
+ exit 1
28
+ fi
29
+
30
+ echo "βœ… Virtual environment created"
31
+ echo ""
32
+
33
+ # Activate virtual environment
34
+ echo "πŸ”Œ Activating virtual environment..."
35
+ source venv/bin/activate
36
+
37
+ if [ $? -ne 0 ]; then
38
+ echo "❌ Failed to activate virtual environment"
39
+ exit 1
40
+ fi
41
+
42
+ echo "βœ… Virtual environment activated"
43
+ echo ""
44
+
45
+ # Install dependencies
46
+ echo "πŸ“₯ Installing dependencies..."
47
+ pip install --upgrade pip
48
+ pip install -r requirements.txt
49
+
50
+ if [ $? -ne 0 ]; then
51
+ echo "❌ Failed to install dependencies"
52
+ exit 1
53
+ fi
54
+
55
+ echo "βœ… Dependencies installed"
56
+ echo ""
57
+
58
+ # Setup .env file
59
+ if [ ! -f .env ]; then
60
+ echo "πŸ“ Setting up .env file..."
61
+ cp .env.example .env
62
+ echo "βœ… .env file created from template"
63
+ echo ""
64
+ echo "⚠️ IMPORTANT: Edit .env and add your API keys:"
65
+ echo " - GEMINI_API_KEY"
66
+ echo " - COINGECKO_API_KEY"
67
+ echo ""
68
+ else
69
+ echo "βœ… .env file already exists"
70
+ echo ""
71
+ fi
72
+
73
+ echo "======================================"
74
+ echo "✨ Setup Complete!"
75
+ echo "======================================"
76
+ echo ""
77
+ echo "Next steps:"
78
+ echo "1. Edit .env file and add your API keys"
79
+ echo "2. Activate the virtual environment:"
80
+ echo " source venv/bin/activate"
81
+ echo "3. Run the application:"
82
+ echo " python app.py"
83
+ echo ""
84
+ echo "The app will be available at: http://localhost:7860"
85
+ echo ""
scripts/start.sh ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Startup script to run both FastAPI backend and Streamlit frontend
3
+
4
+ set -e
5
+
6
+ echo "πŸš€ Starting Multi-Agent Assistant..."
7
+
8
+ # Function to handle shutdown
9
+ shutdown() {
10
+ echo "πŸ›‘ Shutting down services..."
11
+ kill $FASTAPI_PID $STREAMLIT_PID 2>/dev/null || true
12
+ wait
13
+ exit 0
14
+ }
15
+
16
+ trap shutdown SIGTERM SIGINT
17
+
18
+ # Start FastAPI backend in background
19
+ echo "πŸ“‘ Starting FastAPI backend on port 8000..."
20
+ uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
21
+ FASTAPI_PID=$!
22
+
23
+ # Wait for FastAPI to be ready
24
+ echo "⏳ Waiting for FastAPI to be ready..."
25
+ for i in {1..30}; do
26
+ if curl -s http://localhost:8000/health > /dev/null 2>&1; then
27
+ echo "βœ… FastAPI is ready!"
28
+ break
29
+ fi
30
+ if [ $i -eq 30 ]; then
31
+ echo "❌ FastAPI failed to start"
32
+ kill $FASTAPI_PID 2>/dev/null || true
33
+ exit 1
34
+ fi
35
+ sleep 2
36
+ done
37
+
38
+ # Start Streamlit frontend in foreground
39
+ echo "🎨 Starting Streamlit UI on port 8501..."
40
+ streamlit run ui/streamlit_app.py \
41
+ --server.port 8501 \
42
+ --server.address 0.0.0.0 \
43
+ --server.headless true \
44
+ --browser.gatherUsageStats false \
45
+ --server.enableCORS false \
46
+ --server.enableXsrfProtection true &
47
+
48
+ STREAMLIT_PID=$!
49
+
50
+ echo "βœ… All services started!"
51
+ echo "πŸ“‘ FastAPI: http://localhost:8000"
52
+ echo "🎨 Streamlit: http://localhost:8501"
53
+
54
+ # Wait for either process to exit
55
+ wait -n
56
+
57
+ # If we get here, one process exited - shut down everything
58
+ shutdown
src/__init__.py ADDED
File without changes
src/agents/__init__.py ADDED
File without changes
src/agents/crypto_agent_mcp.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Crypto Agent using CoinGecko MCP Server via LangChain MCP adapters."""
2
+ import json
3
+ import shutil
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from src.core.config import config
7
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
8
+ from langchain_core.tools import BaseTool
9
+ from langchain_google_genai import ChatGoogleGenerativeAI
10
+ from langchain_mcp_adapters.client import MultiServerMCPClient
11
+
12
+
13
+ def _resolve_executable(candidates: List[str]) -> str:
14
+ """Return first executable path found in PATH."""
15
+ for name in candidates:
16
+ resolved = shutil.which(name)
17
+ if resolved:
18
+ return resolved
19
+ raise FileNotFoundError(f"Unable to locate any of: {', '.join(candidates)}")
20
+
21
+
22
+ def _to_text(payload: Any) -> str:
23
+ """Convert model or tool output into a printable string."""
24
+ if isinstance(payload, str):
25
+ return payload
26
+ try:
27
+ return json.dumps(payload, ensure_ascii=False)
28
+ except TypeError:
29
+ return str(payload)
30
+
31
+
32
+ class CryptoAgentMCP:
33
+ """Agent specialized in cryptocurrency data using CoinGecko MCP Server."""
34
+
35
+ def __init__(self, use_public_endpoint: bool = False):
36
+ self.name = "Crypto Agent (MCP)"
37
+ self.description = "Cryptocurrency market data and analysis expert using CoinGecko MCP Server"
38
+ self.use_public_endpoint = use_public_endpoint
39
+ self.mcp_client: Optional[MultiServerMCPClient] = None
40
+ self.model: Optional[ChatGoogleGenerativeAI] = None
41
+ self.model_with_tools = None
42
+ self.tools: List[BaseTool] = []
43
+ self.tool_map: Dict[str, BaseTool] = {}
44
+
45
+ async def initialize(self) -> None:
46
+ """Initialize the agent with CoinGecko MCP Server."""
47
+ print(f"πŸ”§ Initializing {self.name}...")
48
+
49
+ try:
50
+ # Connect to CoinGecko MCP Server
51
+ print(f" πŸ“‘ Connecting to CoinGecko MCP Server...")
52
+
53
+ connection_name = "coingecko"
54
+ connections: Dict[str, Dict[str, Any]] = {}
55
+
56
+ api_key = (config.COINGECKO_API_KEY or "").strip()
57
+ if api_key.lower().startswith("demo"):
58
+ print(" Demo API key detected. Using public endpoint with limited access...")
59
+ self.use_public_endpoint = True
60
+
61
+ if self.use_public_endpoint or not api_key:
62
+ print(" Using public SSE endpoint...")
63
+ connections[connection_name] = {
64
+ "transport": "sse",
65
+ "url": "https://mcp.api.coingecko.com/sse",
66
+ }
67
+ else:
68
+ print(" Using Pro endpoint with API key...")
69
+ npx_executable = _resolve_executable(["npx.cmd", "npx.exe", "npx"])
70
+ env = {
71
+ "COINGECKO_PRO_API_KEY": api_key,
72
+ "COINGECKO_ENVIRONMENT": "pro",
73
+ }
74
+ connections[connection_name] = {
75
+ "transport": "stdio",
76
+ "command": npx_executable,
77
+ "args": ["-y", "@coingecko/coingecko-mcp"],
78
+ "env": env,
79
+ }
80
+
81
+ self.mcp_client = MultiServerMCPClient(connections)
82
+
83
+ # Load MCP tools as LangChain tools
84
+ self.tools = await self.mcp_client.get_tools(server_name=connection_name)
85
+ if not self.tools:
86
+ raise RuntimeError("No tools available from CoinGecko MCP Server")
87
+
88
+ self.tool_map = {tool.name: tool for tool in self.tools}
89
+
90
+ # Initialize Gemini chat model bound to tools
91
+ self.model = ChatGoogleGenerativeAI(
92
+ model="gemini-2.5-flash",
93
+ temperature=0.1,
94
+ google_api_key=config.GOOGLE_API_KEY,
95
+ )
96
+ self.model_with_tools = self.model.bind_tools(self.tools)
97
+
98
+ print(f" βœ… Connected to CoinGecko MCP Server with {len(self.tools)} tools")
99
+
100
+ print(f" βœ… {self.name} ready!")
101
+
102
+ except Exception as e:
103
+ import traceback
104
+ print(f" ❌ Error initializing {self.name}: {e}")
105
+ print(f" πŸ“‹ Full error details:")
106
+ traceback.print_exc()
107
+ raise
108
+
109
+ async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
110
+ """Process a query using CoinGecko MCP Server tools."""
111
+ try:
112
+ print(f"\nπŸ’° {self.name} processing: '{query}'")
113
+ messages: List[Any] = []
114
+ if history:
115
+ trimmed_history = history[-10:]
116
+ for turn in trimmed_history:
117
+ user_text = turn.get("user")
118
+ if user_text:
119
+ messages.append(HumanMessage(content=user_text))
120
+ assistant_text = turn.get("assistant")
121
+ if assistant_text:
122
+ messages.append(AIMessage(content=assistant_text))
123
+ messages.append(HumanMessage(content=query))
124
+ final_response = ""
125
+ tool_calls_info = []
126
+
127
+ while True:
128
+ if not self.model_with_tools:
129
+ raise RuntimeError("Model not initialized with tools")
130
+
131
+ ai_message = await self.model_with_tools.ainvoke(messages)
132
+ messages.append(ai_message)
133
+
134
+ tool_calls = getattr(ai_message, "tool_calls", [])
135
+ if not tool_calls:
136
+ final_response = _to_text(ai_message.content)
137
+ break
138
+
139
+ for call in tool_calls:
140
+ tool_name = call.get("name")
141
+ tool_args = call.get("args", {})
142
+ tool_call_id = call.get("id")
143
+ print(f" πŸ”§ MCP Tool call: {tool_name}({tool_args})")
144
+ tool_calls_info.append(f"πŸ”§ MCP Tool call: {tool_name}({tool_args})")
145
+
146
+ tool = self.tool_map.get(tool_name)
147
+ if not tool:
148
+ tool_result = {"error": f"Tool '{tool_name}' not found"}
149
+ else:
150
+ tool_result = await tool.ainvoke(tool_args)
151
+
152
+ messages.append(
153
+ ToolMessage(
154
+ content=_to_text(tool_result),
155
+ tool_call_id=tool_call_id or "",
156
+ )
157
+ )
158
+
159
+ return {
160
+ "success": True,
161
+ "agent": self.name,
162
+ "response": final_response,
163
+ "query": query,
164
+ "tool_calls": tool_calls_info
165
+ }
166
+
167
+ except Exception as e:
168
+ print(f" ❌ Error in {self.name}: {e}")
169
+ import traceback
170
+ traceback.print_exc()
171
+ return {
172
+ "success": False,
173
+ "agent": self.name,
174
+ "error": str(e),
175
+ "query": query
176
+ }
177
+
178
+ async def cleanup(self) -> None:
179
+ """Cleanup resources."""
180
+ print(f"🧹 {self.name} cleaned up")
src/agents/finance_tracker_agent_mcp.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Finance Tracker Agent using MCP Toolbox Docker server via HTTP."""
2
+ import json
3
+ from typing import Any, Dict, List, Optional, Literal
4
+ from pydantic import BaseModel, Field
5
+
6
+ from src.core.config import config
7
+ from langchain_google_genai import ChatGoogleGenerativeAI
8
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
9
+ from toolbox_langchain import ToolboxClient
10
+
11
+
12
+ def _to_text(payload: Any) -> str:
13
+ """Convert model or tool output into a printable string."""
14
+ if isinstance(payload, str):
15
+ return payload
16
+ try:
17
+ return json.dumps(payload, ensure_ascii=False)
18
+ except TypeError:
19
+ return str(payload)
20
+
21
+
22
+ # Pydantic schemas for structured output
23
+ class PortfolioHolding(BaseModel):
24
+ """A single stock holding in the portfolio."""
25
+ symbol: str = Field(description="Stock ticker symbol (e.g., AAPL, MSFT)")
26
+ quantity: float = Field(description="Number of shares held")
27
+ avg_cost_basis: float = Field(description="Average cost per share")
28
+ total_invested: float = Field(description="Total amount invested (quantity * avg_cost_basis)")
29
+ realized_gains: Optional[float] = Field(default=None, description="Realized gains/losses from sales")
30
+
31
+
32
+ class TransactionRecord(BaseModel):
33
+ """A stock transaction record."""
34
+ symbol: str = Field(description="Stock ticker symbol")
35
+ transaction_type: Literal["BUY", "SELL"] = Field(description="Type of transaction")
36
+ quantity: float = Field(description="Number of shares")
37
+ price: float = Field(description="Price per share")
38
+ transaction_date: str = Field(description="Date of transaction")
39
+ notes: Optional[str] = Field(default=None, description="Additional notes")
40
+
41
+
42
+ class PortfolioResponse(BaseModel):
43
+ """Structured response for portfolio queries."""
44
+ response_type: Literal["portfolio_view", "transaction_added", "transaction_history", "general_response"] = Field(
45
+ description="Type of response being provided"
46
+ )
47
+ summary: str = Field(description="Brief summary of the response")
48
+ holdings: Optional[List[PortfolioHolding]] = Field(
49
+ default=None,
50
+ description="Current portfolio holdings (for portfolio_view)"
51
+ )
52
+ transaction: Optional[TransactionRecord] = Field(
53
+ default=None,
54
+ description="Transaction details (for transaction_added)"
55
+ )
56
+ transactions: Optional[List[TransactionRecord]] = Field(
57
+ default=None,
58
+ description="List of transactions (for transaction_history)"
59
+ )
60
+ total_portfolio_value: Optional[float] = Field(
61
+ default=None,
62
+ description="Total portfolio value"
63
+ )
64
+ insights: Optional[List[str]] = Field(
65
+ default=None,
66
+ description="Key insights or recommendations"
67
+ )
68
+
69
+
70
+ def format_portfolio_response(response: PortfolioResponse) -> str:
71
+ """Format structured portfolio response into readable text."""
72
+ output = []
73
+
74
+ # Add summary
75
+ output.append(response.summary)
76
+ output.append("")
77
+
78
+ # Format holdings if present
79
+ if response.holdings:
80
+ output.append("## Current Portfolio Holdings")
81
+ output.append("")
82
+ # Create table header
83
+ output.append("| Symbol | Shares | Avg Cost | Total Invested | Realized Gains |")
84
+ output.append("|--------|--------|----------|----------------|----------------|")
85
+ # Add each holding as a table row
86
+ for holding in response.holdings:
87
+ realized_gains = f"${holding.realized_gains:.2f}" if holding.realized_gains is not None else "$0.00"
88
+ output.append(
89
+ f"| {holding.symbol} | {holding.quantity} | "
90
+ f"${holding.avg_cost_basis:.2f} | ${holding.total_invested:.2f} | {realized_gains} |"
91
+ )
92
+ output.append("")
93
+
94
+ # Format single transaction if present
95
+ if response.transaction:
96
+ output.append("## Transaction Details")
97
+ output.append("")
98
+ t = response.transaction
99
+ output.append(f"- **Type**: {t.transaction_type}")
100
+ output.append(f"- **Symbol**: {t.symbol}")
101
+ output.append(f"- **Quantity**: {t.quantity} shares")
102
+ output.append(f"- **Price**: ${t.price:.2f} per share")
103
+ output.append(f"- **Date**: {t.transaction_date}")
104
+ output.append(f"- **Total Amount**: ${t.quantity * t.price:.2f}")
105
+ if t.notes:
106
+ output.append(f"- **Notes**: {t.notes}")
107
+ output.append("")
108
+
109
+ # Format transaction history if present
110
+ if response.transactions:
111
+ output.append("## Transaction History")
112
+ output.append("")
113
+ for t in response.transactions:
114
+ output.append(f"**{t.transaction_date}** - {t.transaction_type} {t.quantity} shares of {t.symbol} @ ${t.price:.2f}")
115
+ if t.notes:
116
+ output.append(f" Notes: {t.notes}")
117
+ output.append("")
118
+
119
+ # Add total portfolio value if present
120
+ if response.total_portfolio_value is not None:
121
+ output.append(f"**Total Portfolio Value**: ${response.total_portfolio_value:.2f}")
122
+ output.append("")
123
+
124
+ # Add insights if present
125
+ if response.insights:
126
+ output.append("## Key Insights")
127
+ output.append("")
128
+ for insight in response.insights:
129
+ output.append(f"- {insight}")
130
+ output.append("")
131
+
132
+ return "\n".join(output).strip()
133
+
134
+
135
+ class FinanceTrackerMCP:
136
+ """
137
+ Agent for managing personal investment portfolio using MCP Toolbox HTTP server.
138
+
139
+ Features:
140
+ - Portfolio Management: Track stock purchases/sales via Cloud SQL
141
+ - Position Analysis: View holdings, cost basis, realized gains
142
+ - Performance Metrics: Calculate gains/losses with current prices
143
+ - Database Operations: Full CRUD via MCP Toolbox Docker container
144
+ - Secure Access: Uses Google Cloud SQL with MCP Toolbox connector
145
+ """
146
+
147
+ def __init__(self):
148
+ self.name = "Finance Tracker (MCP)"
149
+ self.description = "Personal portfolio tracking and analysis using Cloud SQL via MCP Toolbox"
150
+ self.toolbox_client: Optional[ToolboxClient] = None
151
+ self.model: Optional[ChatGoogleGenerativeAI] = None
152
+ self.model_with_tools = None
153
+ self.model_structured = None
154
+ self.tools: List[Any] = []
155
+ self.tool_map: Dict[str, Any] = {}
156
+ self.initialized = False
157
+
158
+ async def initialize(self) -> None:
159
+ """Initialize the agent with MCP Toolbox HTTP server."""
160
+ print(f"πŸ’Ό Initializing {self.name}...")
161
+
162
+ try:
163
+ # Connect to MCP Toolbox HTTP server (Docker container)
164
+ print(f" πŸ“‘ Connecting to MCP Toolbox HTTP server...")
165
+ print(f" Server URL: {config.MCP_TOOLBOX_SERVER_URL}")
166
+
167
+ # Create toolbox client and enter async context manager
168
+ self.toolbox_client = ToolboxClient(config.MCP_TOOLBOX_SERVER_URL)
169
+ # Manually enter the async context manager to keep connection alive
170
+ await self.toolbox_client.__aenter__()
171
+
172
+ # Load database tools from toolbox
173
+ print(" Loading tools from MCP Toolbox...")
174
+ self.tools = self.toolbox_client.load_toolset()
175
+
176
+ if not self.tools:
177
+ raise RuntimeError(
178
+ f"No tools available from MCP Toolbox server at {config.MCP_TOOLBOX_SERVER_URL}\n"
179
+ f"Make sure the Docker container is running: docker-compose up -d"
180
+ )
181
+
182
+ self.tool_map = {tool.name: tool for tool in self.tools}
183
+
184
+ # Initialize Gemini chat model bound to tools
185
+ self.model = ChatGoogleGenerativeAI(
186
+ model="gemini-2.5-flash",
187
+ temperature=0.1,
188
+ google_api_key=config.GOOGLE_API_KEY,
189
+ )
190
+ self.model_with_tools = self.model.bind_tools(self.tools)
191
+
192
+ # Initialize model with structured output for final responses
193
+ self.model_structured = self.model.with_structured_output(PortfolioResponse)
194
+
195
+ print(f" βœ… Connected to MCP Toolbox with {len(self.tools)} tools")
196
+ print(f" πŸ“‹ Available MCP Toolbox capabilities:")
197
+ print(f" - Database queries (SELECT, INSERT, UPDATE, DELETE)")
198
+ print(f" - Schema introspection (tables, columns, relationships)")
199
+ print(f" - Transaction management")
200
+ print(f" - Natural language to SQL conversion")
201
+ print(f" Total: {len(self.tools)} tools available")
202
+
203
+ self.initialized = True
204
+ print(f" βœ… {self.name} ready!")
205
+
206
+ except Exception as e:
207
+ import traceback
208
+ print(f" ❌ Error initializing {self.name}: {e}")
209
+ print(f" πŸ“‹ Full error details:")
210
+ traceback.print_exc()
211
+ print(f"\nπŸ’‘ Troubleshooting:")
212
+ print(f" 1. Make sure Docker is running")
213
+ print(f" 2. Start MCP Toolbox container: docker-compose up -d")
214
+ print(f" 3. Check container status: docker-compose ps")
215
+ print(f" 4. View container logs: docker-compose logs mcp-toolbox")
216
+ print(f" 5. Verify server is accessible: curl {config.MCP_TOOLBOX_SERVER_URL}/health")
217
+ raise
218
+
219
+ async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
220
+ """Process a query using MCP Toolbox for Cloud SQL operations."""
221
+ try:
222
+ if not self.initialized:
223
+ await self.initialize()
224
+
225
+ print(f"\nπŸ’Ό {self.name} processing: '{query}'")
226
+
227
+ # Build system message with portfolio context
228
+ system_message = """You are a Finance Tracker Agent specialized in portfolio management.
229
+
230
+ You have access to a Cloud SQL PostgreSQL database through MCP Toolbox with the following schema:
231
+
232
+ Tables:
233
+ - stock_transactions: Stores all BUY/SELL transactions (symbol, transaction_type, quantity, price, transaction_date, notes)
234
+ - portfolio_positions: Current aggregated positions (symbol, total_quantity, avg_cost_basis, total_invested, realized_gains)
235
+ - portfolio_snapshots: Historical portfolio value tracking
236
+ - stock_metadata: Cached stock information (company_name, sector, industry, market_cap)
237
+
238
+ When users want to:
239
+ - ADD a transaction: INSERT into stock_transactions (triggers will update portfolio_positions automatically)
240
+ - VIEW portfolio: SELECT from portfolio_positions
241
+ - CHECK transaction history: SELECT from stock_transactions
242
+ - ANALYZE performance: Query portfolio_positions with calculations
243
+
244
+ Always use the MCP Toolbox database tools to:
245
+ 1. Query the database for current data
246
+ 2. Insert/update records as needed
247
+ 3. Calculate metrics (gains, losses, allocations)
248
+ 4. Provide clear, actionable insights
249
+
250
+ Be helpful, accurate, and provide investment insights based on their data."""
251
+
252
+ messages: List[Any] = [HumanMessage(content=system_message)]
253
+
254
+ # Add conversation history
255
+ if history:
256
+ trimmed_history = history[-10:]
257
+ for turn in trimmed_history:
258
+ user_text = turn.get("user")
259
+ if user_text:
260
+ messages.append(HumanMessage(content=user_text))
261
+ assistant_text = turn.get("assistant")
262
+ if assistant_text:
263
+ messages.append(AIMessage(content=assistant_text))
264
+
265
+ messages.append(HumanMessage(content=query))
266
+
267
+ # Tool calling loop - gather data from database
268
+ tool_calls_info = []
269
+ while True:
270
+ if not self.model_with_tools:
271
+ raise RuntimeError("Model not initialized with tools")
272
+
273
+ ai_message = await self.model_with_tools.ainvoke(messages)
274
+ messages.append(ai_message)
275
+
276
+ tool_calls = getattr(ai_message, "tool_calls", [])
277
+ if not tool_calls:
278
+ # No more tool calls - exit loop
279
+ break
280
+
281
+ for call in tool_calls:
282
+ tool_name = call.get("name")
283
+ tool_args = call.get("args", {})
284
+ tool_call_id = call.get("id")
285
+ print(f" πŸ”§ MCP Toolbox call: {tool_name}({json.dumps(tool_args, indent=2)})")
286
+ tool_calls_info.append(f"πŸ”§ MCP Toolbox call: {tool_name}({json.dumps(tool_args)})")
287
+
288
+ tool = self.tool_map.get(tool_name)
289
+ if not tool:
290
+ tool_result = {"error": f"Tool '{tool_name}' not found"}
291
+ else:
292
+ tool_result = await tool.ainvoke(tool_args)
293
+
294
+ messages.append(
295
+ ToolMessage(
296
+ content=_to_text(tool_result),
297
+ tool_call_id=tool_call_id or "",
298
+ )
299
+ )
300
+
301
+ # Generate structured response based on all gathered information
302
+ print(" πŸ“Š Generating structured response...")
303
+ structured_prompt = """Based on the database query results, provide a structured response.
304
+
305
+ Guidelines:
306
+ - For portfolio viewing: Set response_type to "portfolio_view" and populate holdings list
307
+ - For adding transactions: Set response_type to "transaction_added" and populate transaction field
308
+ - For transaction history: Set response_type to "transaction_history" and populate transactions list
309
+ - For other queries: Set response_type to "general_response"
310
+ - Always provide a clear summary
311
+ - Include relevant insights when possible"""
312
+
313
+ messages.append(HumanMessage(content=structured_prompt))
314
+
315
+ if not self.model_structured:
316
+ raise RuntimeError("Structured output model not initialized")
317
+
318
+ structured_response = await self.model_structured.ainvoke(messages)
319
+
320
+ # Format the structured response into readable text
321
+ final_response = format_portfolio_response(structured_response)
322
+
323
+ return {
324
+ "success": True,
325
+ "agent": self.name,
326
+ "response": final_response,
327
+ "query": query,
328
+ "structured_data": structured_response.model_dump(), # Include raw structured data
329
+ "tool_calls": tool_calls_info
330
+ }
331
+
332
+ except Exception as e:
333
+ print(f" ❌ Error in {self.name}: {e}")
334
+ import traceback
335
+ traceback.print_exc()
336
+ return {
337
+ "success": False,
338
+ "agent": self.name,
339
+ "error": str(e),
340
+ "query": query
341
+ }
342
+
343
+ async def cleanup(self) -> None:
344
+ """Cleanup resources."""
345
+ if self.toolbox_client:
346
+ try:
347
+ # Exit the async context manager
348
+ await self.toolbox_client.__aexit__(None, None, None)
349
+ except Exception as e:
350
+ print(f" ⚠️ Warning during cleanup: {e}")
351
+ print(f"🧹 {self.name} cleaned up")
src/agents/rag_agent_mcp.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG Agent using Chroma MCP Server via LangChain MCP adapters."""
2
+ import json
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import chromadb
10
+ from src.core.config import config
11
+ from src.utils.file_processors import process_document
12
+ from langchain_core.tools import BaseTool
13
+ from langchain_google_genai import ChatGoogleGenerativeAI
14
+ from langchain_mcp_adapters.client import MultiServerMCPClient
15
+
16
+
17
+ class RAGAgentMCP:
18
+ """Agent specialized in document retrieval and Q&A using Chroma MCP Server."""
19
+
20
+ @staticmethod
21
+ def _to_text(payload: Any) -> str:
22
+ """Convert tool or model output into a displayable string."""
23
+ if isinstance(payload, str):
24
+ return payload
25
+ try:
26
+ return json.dumps(payload, ensure_ascii=False)
27
+ except TypeError:
28
+ return str(payload)
29
+
30
+ def __init__(self):
31
+ self.name = "RAG Agent (MCP)"
32
+ self.description = "Document storage, retrieval, and question answering expert using Chroma MCP Server"
33
+ # MCP Server client
34
+ self.mcp_client: Optional[MultiServerMCPClient] = None
35
+
36
+ # Direct ChromaDB client (for document upload only)
37
+ self.chroma_direct_client = None
38
+ self.collection = None
39
+
40
+ # Gemini model
41
+ self.model: Optional[ChatGoogleGenerativeAI] = None
42
+ self.model_with_tools = None
43
+ self.tools: List[BaseTool] = []
44
+ self.tool_map: Dict[str, BaseTool] = {}
45
+
46
+ async def initialize(self) -> None:
47
+ """Initialize the agent with direct ChromaDB client."""
48
+ print(f"πŸ”§ Initializing {self.name}...")
49
+
50
+ try:
51
+ # MCP Server integration commented out - using direct client instead
52
+ # # 1. Connect to Chroma MCP Server for tool access
53
+ # print(f" πŸ“‘ Connecting to Chroma MCP Server...")
54
+ # chroma_connection = self._build_chroma_connection()
55
+ # self.mcp_client = MultiServerMCPClient({"chroma": chroma_connection})
56
+ #
57
+ # # Get tools from MCP server
58
+ # self.tools = await self.mcp_client.get_tools(server_name="chroma")
59
+ # if not self.tools:
60
+ # raise RuntimeError("No tools returned by Chroma MCP server")
61
+ #
62
+ # self.tool_map = {tool.name: tool for tool in self.tools}
63
+ #
64
+ # print(f" βœ… Connected to Chroma MCP Server with {len(self.tools)} tools")
65
+
66
+ # 1. Connect to ChromaDB Cloud with direct client
67
+ print(f" πŸ“š Connecting to ChromaDB Cloud...")
68
+ self.chroma_direct_client = chromadb.CloudClient(
69
+ tenant=config.CHROMA_TENANT,
70
+ database=config.CHROMA_DATABASE,
71
+ api_key=config.CHROMA_API_KEY
72
+ )
73
+
74
+ # 2. Use Google Gemini embedding function
75
+ print(f" πŸ”§ Setting up Gemini embeddings...")
76
+ from chromadb.utils.embedding_functions import GoogleGenerativeAiEmbeddingFunction
77
+ embedding_function = GoogleGenerativeAiEmbeddingFunction(
78
+ api_key=config.GOOGLE_API_KEY,
79
+ model_name="models/embedding-001"
80
+ )
81
+
82
+ # 3. Get or create collection with Gemini embeddings
83
+ collection_name = "gemini-embed"
84
+ self.collection = self.chroma_direct_client.get_or_create_collection(
85
+ name=collection_name,
86
+ metadata={"description": "Document storage for RAG with Gemini embeddings"},
87
+ embedding_function=embedding_function
88
+ )
89
+ print(f" πŸ“¦ Using collection: {collection_name}")
90
+ print(f" πŸ“„ Current documents in collection: {self.collection.count()}")
91
+
92
+ # 4. Initialize Gemini model for answer generation
93
+ self.model = ChatGoogleGenerativeAI(
94
+ model="gemini-2.5-flash",
95
+ temperature=0.1,
96
+ google_api_key=config.GOOGLE_API_KEY,
97
+ )
98
+
99
+
100
+ print(f" βœ… {self.name} ready!")
101
+
102
+ except Exception as e:
103
+ import traceback
104
+ print(f" ❌ Error initializing {self.name}: {e}")
105
+ print(f" πŸ“‹ Full error details:")
106
+ traceback.print_exc()
107
+ raise
108
+
109
+ async def add_document(
110
+ self,
111
+ file_path: str,
112
+ metadata: Optional[Dict[str, Any]] = None,
113
+ progress_callback: Optional[callable] = None
114
+ ) -> Dict[str, Any]:
115
+ """
116
+ Add a document to ChromaDB via MCP server.
117
+
118
+ Note: This uses direct ChromaDB client as MCP add_documents tool
119
+ requires the collection to already exist and documents to be processed.
120
+
121
+ Args:
122
+ file_path: Path to the document file
123
+ metadata: Optional metadata for the document
124
+ progress_callback: Optional callback for progress updates
125
+
126
+ Returns:
127
+ Dictionary with status and document info
128
+ """
129
+ try:
130
+ doc_path = Path(file_path)
131
+
132
+ # Validate file type
133
+ if doc_path.suffix.lower() not in config.ALLOWED_FILE_TYPES:
134
+ return {
135
+ "success": False,
136
+ "error": f"Unsupported file type: {doc_path.suffix}. Allowed: {config.ALLOWED_FILE_TYPES}"
137
+ }
138
+
139
+ print(f"\nπŸ“„ Processing {doc_path.suffix.upper()}: {doc_path.name}")
140
+
141
+ if progress_callback:
142
+ progress_callback(0.1, "Extracting text from document...")
143
+
144
+ # Process document using file_processors
145
+ doc_info = process_document(doc_path, chunk_size=500, overlap=50)
146
+
147
+ if progress_callback:
148
+ progress_callback(0.4, f"Extracted {doc_info['num_chunks']} chunks...")
149
+
150
+ # Generate document ID
151
+ doc_id = doc_path.stem
152
+
153
+ # Prepare metadata
154
+ doc_metadata = {
155
+ "filename": doc_info["filename"],
156
+ "file_type": doc_info["file_type"],
157
+ "file_size": doc_info["file_size"],
158
+ "num_chunks": doc_info["num_chunks"],
159
+ "source": "user_upload",
160
+ **(metadata or {})
161
+ }
162
+
163
+ # MCP Server upload (commented out - using direct client instead)
164
+ # if progress_callback:
165
+ # progress_callback(0.6, "Uploading via Chroma MCP Server...")
166
+ #
167
+ # # Prepare data for MCP add_documents tool
168
+ # chunks = doc_info["chunks"]
169
+ # ids = [f"{doc_id}_chunk_{i}" for i in range(len(chunks))]
170
+ # metadatas = [
171
+ # {**doc_metadata, "chunk_index": i, "chunk_id": ids[i]}
172
+ # for i in range(len(chunks))
173
+ # ]
174
+ #
175
+ # # Try using MCP server's add_documents tool
176
+ # add_tool = (
177
+ # self.tool_map.get("chroma_add_documents")
178
+ # or self.tool_map.get("add_documents")
179
+ # )
180
+ # if not add_tool:
181
+ # raise KeyError("add_documents tool not available in Chroma MCP")
182
+ #
183
+ # tool_payload = {
184
+ # "collection_name": "gemini-embed", # Use dedicated Gemini collection
185
+ # "documents": chunks,
186
+ # "ids": ids,
187
+ # "metadatas": metadatas,
188
+ # }
189
+ #
190
+ # result = await add_tool.ainvoke(tool_payload)
191
+ #
192
+ # if isinstance(result, dict) and result.get("success"):
193
+ # print(f" βœ… Added via MCP: {len(chunks)} chunks from {doc_path.name}")
194
+ # else:
195
+ # raise ValueError(result)
196
+
197
+ # Direct ChromaDB client upload
198
+ if progress_callback:
199
+ progress_callback(0.6, "Uploading to ChromaDB with Google embeddings...")
200
+
201
+ chunks = doc_info["chunks"]
202
+ ids = [f"{doc_id}_chunk_{i}" for i in range(len(chunks))]
203
+ metadatas = [
204
+ {**doc_metadata, "chunk_index": i, "chunk_id": ids[i]}
205
+ for i in range(len(chunks))
206
+ ]
207
+
208
+ self.collection.add(
209
+ documents=chunks,
210
+ ids=ids,
211
+ metadatas=metadatas
212
+ )
213
+ print(f" βœ… Added {len(chunks)} chunks from {doc_path.name} to '{self.collection.name}'")
214
+
215
+ if progress_callback:
216
+ progress_callback(1.0, "Upload complete!")
217
+
218
+ return {
219
+ "success": True,
220
+ "document_id": doc_id,
221
+ "filename": doc_path.name,
222
+ "file_type": doc_info["file_type"],
223
+ "chunks_added": len(chunks),
224
+ "total_documents": self.collection.count()
225
+ }
226
+
227
+ except Exception as e:
228
+ print(f" ❌ Error adding document: {e}")
229
+ import traceback
230
+ traceback.print_exc()
231
+ return {
232
+ "success": False,
233
+ "error": str(e)
234
+ }
235
+
236
+ async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
237
+ """Process a query using direct ChromaDB client and Gemini."""
238
+ try:
239
+ print(f"\nπŸ“š {self.name} processing: '{query}'")
240
+
241
+ # MCP Tool-based approach (commented out - using direct client instead)
242
+ # if not self.model_with_tools:
243
+ # raise RuntimeError("Model not initialized with tools")
244
+ #
245
+ # messages: List[Any] = [HumanMessage(content=query)]
246
+ # final_response = ""
247
+ #
248
+ # while True:
249
+ # ai_message = await self.model_with_tools.ainvoke(messages)
250
+ # messages.append(ai_message)
251
+ #
252
+ # tool_calls = getattr(ai_message, "tool_calls", [])
253
+ # if not tool_calls:
254
+ # final_response = self._to_text(ai_message.content)
255
+ # break
256
+ #
257
+ # for call in tool_calls:
258
+ # tool_name = call.get("name")
259
+ # tool_args = call.get("args", {})
260
+ # tool_call_id = call.get("id")
261
+ # print(f" πŸ”§ MCP Tool call: {tool_name}({tool_args})")
262
+ #
263
+ # tool = self.tool_map.get(tool_name)
264
+ # if not tool:
265
+ # tool_result = {"error": f"Tool '{tool_name}' not found"}
266
+ # else:
267
+ # tool_result = await tool.ainvoke(tool_args)
268
+ #
269
+ # messages.append(
270
+ # ToolMessage(
271
+ # content=self._to_text(tool_result),
272
+ # tool_call_id=tool_call_id or "",
273
+ # )
274
+ # )
275
+
276
+ # Direct ChromaDB query approach
277
+ if not self.model:
278
+ raise RuntimeError("Model not initialized")
279
+
280
+ conversation_context = ""
281
+ if history:
282
+ trimmed_history = history[-6:]
283
+ history_lines: List[str] = []
284
+ for turn in trimmed_history:
285
+ user_text = turn.get("user", "")
286
+ # Handle both string and list types
287
+ if isinstance(user_text, str):
288
+ user_text = user_text.strip()
289
+ elif isinstance(user_text, list):
290
+ user_text = " ".join(str(x) for x in user_text).strip()
291
+ else:
292
+ user_text = str(user_text).strip()
293
+
294
+ if user_text:
295
+ history_lines.append(f"User: {user_text}")
296
+
297
+ assistant_text = turn.get("assistant", "")
298
+ # Handle both string and list types
299
+ if isinstance(assistant_text, str):
300
+ assistant_text = assistant_text.strip()
301
+ elif isinstance(assistant_text, list):
302
+ assistant_text = " ".join(str(x) for x in assistant_text).strip()
303
+ else:
304
+ assistant_text = str(assistant_text).strip()
305
+
306
+ if assistant_text:
307
+ history_lines.append(f"Assistant: {assistant_text}")
308
+ if history_lines:
309
+ conversation_context = "\n".join(history_lines)
310
+
311
+ conversational_query = query
312
+ if conversation_context:
313
+ try:
314
+ rephrase_prompt = f"""You are a helpful assistant that rewrites follow-up questions.
315
+ Use the provided conversation history to rewrite the latest user input so it is a standalone question for document retrieval.
316
+
317
+ Conversation history:
318
+ {conversation_context}
319
+
320
+ Latest user input: {query}
321
+
322
+ Respond with only the rewritten standalone question."""
323
+
324
+ # Stream the rephrase response
325
+ candidate_content = ""
326
+ async for chunk in self.model.astream(rephrase_prompt):
327
+ if hasattr(chunk, 'content') and chunk.content:
328
+ candidate_content += chunk.content
329
+
330
+ candidate = candidate_content.strip()
331
+ if candidate:
332
+ conversational_query = candidate
333
+ except Exception as rephrase_error:
334
+ print(f" ⚠️ Could not rewrite query from history: {rephrase_error}")
335
+
336
+ # Query the collection with Google embeddings
337
+ print(f" πŸ” Querying collection '{self.collection.name}'...")
338
+ results = self.collection.query(
339
+ query_texts=[conversational_query],
340
+ n_results=5 # Get top 5 most relevant chunks
341
+ )
342
+
343
+ # Extract documents and metadata
344
+ documents = results.get('documents', [[]])[0]
345
+ metadatas = results.get('metadatas', [[]])[0]
346
+ distances = results.get('distances', [[]])[0]
347
+
348
+ if not documents:
349
+ print(f" ⚠️ No relevant documents found")
350
+ return {
351
+ "success": True,
352
+ "agent": self.name,
353
+ "response": "I couldn't find any relevant information in the uploaded documents to answer your question.",
354
+ "query": query
355
+ }
356
+
357
+ print(f" πŸ“„ Found {len(documents)} relevant chunks")
358
+
359
+ # Build context from retrieved documents
360
+ context = "\n\n".join([
361
+ f"[Document: {meta.get('filename', 'Unknown')} - Chunk {meta.get('chunk_index', '?')}]\n{doc}"
362
+ for doc, meta in zip(documents, metadatas)
363
+ ])
364
+
365
+ # Create prompt with context
366
+ history_section = f"Conversation history:\n{conversation_context}\n\n" if conversation_context else ""
367
+ prompt = f"""Based on the following document excerpts, please answer the question.
368
+
369
+ {history_section}Context from documents:
370
+ {context}
371
+
372
+ Question: {conversational_query}
373
+ Original user input: {query}
374
+
375
+ Please provide a comprehensive answer based on the context above. If the context doesn't contain enough information to answer the question, please say so."""
376
+
377
+ # Generate answer using Gemini with streaming
378
+ print(f" πŸ€– Streaming answer from Gemini...")
379
+ final_response = ""
380
+ async for chunk in self.model.astream(prompt):
381
+ if hasattr(chunk, 'content') and chunk.content:
382
+ final_response += chunk.content
383
+
384
+ return {
385
+ "success": True,
386
+ "agent": self.name,
387
+ "response": final_response,
388
+ "query": query,
389
+ "interpreted_query": conversational_query,
390
+ "sources": [meta.get('filename') for meta in metadatas]
391
+ }
392
+
393
+ except Exception as e:
394
+ print(f" ❌ Error in {self.name}: {e}")
395
+ import traceback
396
+ traceback.print_exc()
397
+ return {
398
+ "success": False,
399
+ "agent": self.name,
400
+ "error": str(e),
401
+ "query": query
402
+ }
403
+
404
+ async def cleanup(self) -> None:
405
+ """Cleanup resources."""
406
+ print(f"🧹 {self.name} cleaned up")
407
+
408
+ def _build_chroma_connection(self) -> Dict[str, Any]:
409
+ """Create connection configuration for the Chroma MCP server."""
410
+ env = {
411
+ "CHROMA_CLIENT_TYPE": "cloud",
412
+ "CHROMA_TENANT": config.CHROMA_TENANT or "",
413
+ "CHROMA_DATABASE": config.CHROMA_DATABASE or "",
414
+ "CHROMA_API_KEY": config.CHROMA_API_KEY or "",
415
+ "CHROMA_CLOUD_HOST": config.CHROMA_CLOUD_HOST,
416
+ }
417
+
418
+ scripts_dir = Path(sys.prefix) / ("Scripts" if os.name == "nt" else "bin")
419
+ candidates = [
420
+ str(scripts_dir / "uvx.exe"),
421
+ str(scripts_dir / "uvx.cmd"),
422
+ str(scripts_dir / "uvx"),
423
+ "uvx.exe",
424
+ "uvx.cmd",
425
+ "uvx",
426
+ ]
427
+
428
+ uvx_path: Optional[str] = None
429
+ for candidate in candidates:
430
+ if not candidate:
431
+ continue
432
+ resolved = shutil.which(candidate)
433
+ if resolved:
434
+ uvx_path = resolved
435
+ break
436
+
437
+ if not uvx_path:
438
+ msg = (
439
+ "Could not locate 'uvx'. Install the 'uv' package inside the virtual "
440
+ "environment or ensure it is on PATH."
441
+ )
442
+ raise FileNotFoundError(msg)
443
+
444
+ return {
445
+ "transport": "stdio",
446
+ "command": uvx_path,
447
+ "args": ["--with", "onnxruntime", "chroma-mcp", "--client-type", "cloud"],
448
+ "env": env,
449
+ }
src/agents/search_agent_mcp.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Search Agent using DuckDuckGo MCP Server via uvx."""
2
+ import asyncio
3
+ from typing import Dict, Any, Optional, List
4
+ from langchain_google_genai import ChatGoogleGenerativeAI
5
+ from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage
6
+ from langchain_core.tools import BaseTool
7
+ from langchain_mcp_adapters.client import MultiServerMCPClient
8
+ from src.core.config import config
9
+
10
+
11
+ class SearchAgentMCP:
12
+ """
13
+ Agent for performing web searches using DuckDuckGo MCP Server via uvx.
14
+ Follows the same pattern as StockAgentMCP using langchain-mcp-adapters.
15
+ """
16
+
17
+ def __init__(self):
18
+ """Initialize the DuckDuckGo search agent."""
19
+ self.name = "Search Agent (MCP)"
20
+ self.description = "Web search expert using DuckDuckGo MCP Server via uvx"
21
+ self.mcp_client: Optional[MultiServerMCPClient] = None
22
+ self.llm: Optional[ChatGoogleGenerativeAI] = None
23
+ self.llm_with_tools = None
24
+ self.tools: List[BaseTool] = []
25
+ self.tool_map: Dict[str, BaseTool] = {}
26
+ self.initialized = False
27
+
28
+ async def initialize(self):
29
+ """Initialize the agent with MCP client and LLM."""
30
+ if not self.initialized:
31
+ print("πŸ” Initializing Search Agent (MCP)...")
32
+
33
+ try:
34
+ # Connect to DuckDuckGo MCP Server via uvx
35
+ print(" πŸ“‘ Connecting to DuckDuckGo MCP Server...")
36
+
37
+ connection_name = "duckduckgo"
38
+ connections: Dict[str, Dict[str, Any]] = {}
39
+
40
+ # DuckDuckGo MCP Server via uvx (Python package)
41
+ # This works inside Docker containers without Docker-in-Docker
42
+ connections[connection_name] = {
43
+ "transport": "stdio",
44
+ "command": "uvx",
45
+ "args": ["duckduckgo-mcp-server"],
46
+ }
47
+
48
+ print(" Using DuckDuckGo MCP server via uvx...")
49
+
50
+ self.mcp_client = MultiServerMCPClient(connections)
51
+
52
+ # Load MCP tools as LangChain tools
53
+ print(" Loading tools from DuckDuckGo MCP Server...")
54
+ self.tools = await self.mcp_client.get_tools(server_name=connection_name)
55
+
56
+ if not self.tools:
57
+ raise RuntimeError(
58
+ "No tools available from DuckDuckGo MCP Server\n"
59
+ "Make sure uvx is available and duckduckgo-mcp-server can be installed:\n"
60
+ " - Check: uvx --version\n"
61
+ " - Test: uvx duckduckgo-mcp-server --help"
62
+ )
63
+
64
+ self.tool_map = {tool.name: tool for tool in self.tools}
65
+
66
+ # Initialize Gemini chat model bound to tools
67
+ self.llm = ChatGoogleGenerativeAI(
68
+ model="gemini-2.5-flash",
69
+ temperature=0.3,
70
+ google_api_key=config.GOOGLE_API_KEY,
71
+ )
72
+ self.llm_with_tools = self.llm.bind_tools(self.tools)
73
+
74
+ print(f" βœ… Connected to DuckDuckGo MCP Server")
75
+ print(f" πŸ“‹ Available tools: {len(self.tools)}")
76
+ for tool in self.tools:
77
+ print(f" - {tool.name}")
78
+ print(f" βœ… Bound {len(self.tools)} tools to LLM")
79
+
80
+ self.initialized = True
81
+ print(" βœ… Search Agent (MCP) ready!")
82
+
83
+ except Exception as e:
84
+ import traceback
85
+ print(f" ❌ Error initializing Search Agent: {e}")
86
+ print(f" πŸ“‹ Full error details:")
87
+ traceback.print_exc()
88
+ print(f"\nπŸ’‘ Troubleshooting:")
89
+ print(f" 1. Make sure Docker is running: docker ps")
90
+ print(f" 2. Test Docker: docker run --rm hello-world")
91
+ print(f" 3. Pull the image manually: docker pull mcp/duckduckgo")
92
+ print(f" 4. Check Docker is in PATH: where docker (Windows) or which docker (Linux/Mac)")
93
+ raise
94
+
95
+ async def process(
96
+ self,
97
+ query: str,
98
+ history: Optional[List[Dict[str, str]]] = None
99
+ ) -> Dict[str, Any]:
100
+ """
101
+ Process a search query through the DuckDuckGo MCP server.
102
+
103
+ Args:
104
+ query: User's search query
105
+ history: Optional conversation history
106
+
107
+ Returns:
108
+ Dictionary with agent response and optional search_urls
109
+ """
110
+ try:
111
+ if not self.initialized:
112
+ await self.initialize()
113
+
114
+ print(f"\nπŸ” Search Agent processing: '{query}'")
115
+
116
+ # Build system prompt
117
+ system_prompt = """You are a web search assistant with access to DuckDuckGo search.
118
+
119
+ CRITICAL: You MUST use the available tools to find current information. DO NOT answer from memory.
120
+
121
+ Your process:
122
+ 1. Use the search tool with the user's query
123
+ 2. Read the search results carefully
124
+ 3. Synthesize a clear, accurate answer with source citations
125
+
126
+ Always use the search tool first before answering."""
127
+
128
+ # Prepare messages
129
+ messages = [SystemMessage(content=system_prompt)]
130
+
131
+ # Add conversation history if provided (limit to last 2 turns)
132
+ if history:
133
+ for turn in history[-2:]:
134
+ if "user" in turn:
135
+ messages.append(HumanMessage(content=turn["user"]))
136
+ if "assistant" in turn:
137
+ messages.append(AIMessage(content=turn["assistant"]))
138
+
139
+ # Add current query
140
+ messages.append(HumanMessage(content=query))
141
+
142
+ # Track search URLs for references
143
+ search_urls = []
144
+ tool_calls_info = []
145
+
146
+ # Tool calling loop
147
+ max_iterations = 3
148
+ for iteration in range(max_iterations):
149
+ # Get LLM response
150
+ response = await self.llm_with_tools.ainvoke(messages)
151
+ messages.append(response)
152
+
153
+ # Check for tool calls
154
+ tool_calls = getattr(response, 'tool_calls', None) or []
155
+
156
+ if not tool_calls:
157
+ # No more tool calls, return final response
158
+ final_content = response.content if hasattr(response, 'content') else str(response)
159
+ print(f" βœ… Search complete")
160
+ result = {
161
+ "success": True,
162
+ "response": final_content
163
+ }
164
+ if search_urls:
165
+ result["search_urls"] = search_urls
166
+ if tool_calls_info:
167
+ result["tool_calls"] = tool_calls_info
168
+ return result
169
+
170
+ # Execute tool calls
171
+ for tool_call in tool_calls:
172
+ # Handle both dict and object formats
173
+ if isinstance(tool_call, dict):
174
+ tool_name = tool_call.get('name', '')
175
+ tool_args = tool_call.get('args', {})
176
+ tool_id = tool_call.get('id', '')
177
+ else:
178
+ tool_name = getattr(tool_call, 'name', '')
179
+ tool_args = getattr(tool_call, 'args', {})
180
+ tool_id = getattr(tool_call, 'id', '')
181
+
182
+ print(f" πŸ”§ Executing: {tool_name}({tool_args})")
183
+ tool_calls_info.append(f"πŸ”§ DuckDuckGo call: {tool_name}({tool_args})")
184
+
185
+ # Get the tool
186
+ tool = self.tool_map.get(tool_name)
187
+ if not tool:
188
+ tool_result = f"Error: Tool '{tool_name}' not found"
189
+ else:
190
+ try:
191
+ # Call the tool with timeout
192
+ tool_result = await asyncio.wait_for(
193
+ tool.ainvoke(tool_args),
194
+ timeout=30.0
195
+ )
196
+ print(f" βœ… Tool executed successfully")
197
+
198
+ # Extract URLs from search results (safely)
199
+ if tool_name == "search":
200
+ try:
201
+ # Print the raw result to see format
202
+ print(f" πŸ“„ Search result type: {type(tool_result)}")
203
+
204
+ # Convert to string if needed
205
+ result_str = str(tool_result) if not isinstance(tool_result, str) else tool_result
206
+
207
+ # Parse search results to extract URLs
208
+ import re
209
+ # Look for URL patterns in the result
210
+ url_pattern = r'https?://[^\s<>"{}|\\^`\[\]]+'
211
+ found_urls = re.findall(url_pattern, result_str)
212
+
213
+ # Add unique URLs
214
+ for url in found_urls[:5]: # Top 5 URLs
215
+ try:
216
+ # Extract domain as title
217
+ domain = url.split('/')[2] if len(url.split('/')) > 2 else url
218
+ url_data = {"url": url, "title": domain}
219
+ if url_data not in search_urls:
220
+ search_urls.append(url_data)
221
+ print(f" πŸ”— Found URL: {url}")
222
+ except Exception as e:
223
+ print(f" ⚠️ Error parsing URL {url}: {e}")
224
+ continue
225
+ except Exception as e:
226
+ print(f" ⚠️ Error extracting URLs: {e}")
227
+ # Continue anyway, don't break search functionality
228
+
229
+ # Truncate if too long
230
+ if isinstance(tool_result, str) and len(tool_result) > 5000:
231
+ tool_result = tool_result[:5000] + "\n\n[Results truncated]"
232
+ elif isinstance(tool_result, dict):
233
+ result_str = str(tool_result)
234
+ if len(result_str) > 5000:
235
+ tool_result = result_str[:5000] + "\n\n[Results truncated]"
236
+
237
+ except asyncio.TimeoutError:
238
+ tool_result = "Error: Search timed out"
239
+ print(f" ⚠️ Tool call timed out")
240
+ except Exception as e:
241
+ tool_result = f"Error: {str(e)}"
242
+ print(f" ❌ Tool execution failed: {e}")
243
+
244
+ # Add tool result to messages
245
+ messages.append(
246
+ ToolMessage(
247
+ content=str(tool_result),
248
+ tool_call_id=tool_id
249
+ )
250
+ )
251
+
252
+ # If we hit max iterations, return last response
253
+ print(f" ⚠️ Max iterations reached")
254
+ result = {
255
+ "success": True,
256
+ "response": "Search completed but may be incomplete. Try a more specific query."
257
+ }
258
+ if search_urls:
259
+ result["search_urls"] = search_urls
260
+ if tool_calls_info:
261
+ result["tool_calls"] = tool_calls_info
262
+ return result
263
+
264
+ except Exception as e:
265
+ error_msg = f"Error processing search query: {str(e)}"
266
+ print(f" ❌ {error_msg}")
267
+ import traceback
268
+ print(f" Traceback: {traceback.format_exc()}")
269
+ return {
270
+ "success": False,
271
+ "error": error_msg,
272
+ "response": f"I encountered an error: {str(e)}"
273
+ }
274
+
275
+ async def cleanup(self):
276
+ """Cleanup resources."""
277
+ if self.mcp_client:
278
+ try:
279
+ await self.mcp_client.cleanup()
280
+ except Exception as e:
281
+ print(f" ⚠️ Warning during cleanup: {e}")
282
+ self.initialized = False
283
+ print("🧹 Search Agent cleanup complete")
src/agents/stock_agent_mcp.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stock Agent using Alpha Vantage MCP Server via LangChain MCP adapters."""
2
+ import json
3
+ import shutil
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from src.core.config import config
7
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
8
+ from langchain_core.tools import BaseTool
9
+ from langchain_google_genai import ChatGoogleGenerativeAI
10
+ from langchain_mcp_adapters.client import MultiServerMCPClient
11
+
12
+
13
+ def _resolve_executable(candidates: List[str]) -> str:
14
+ """Return first executable path found in PATH."""
15
+ for name in candidates:
16
+ resolved = shutil.which(name)
17
+ if resolved:
18
+ return resolved
19
+ raise FileNotFoundError(f"Unable to locate any of: {', '.join(candidates)}")
20
+
21
+
22
+ def _to_text(payload: Any) -> str:
23
+ """Convert model or tool output into a printable string."""
24
+ if isinstance(payload, str):
25
+ return payload
26
+ try:
27
+ return json.dumps(payload, ensure_ascii=False)
28
+ except TypeError:
29
+ return str(payload)
30
+
31
+
32
+ class StockAgentMCP:
33
+ """Agent specialized in stock market data using Alpha Vantage MCP Server."""
34
+
35
+ def __init__(self):
36
+ self.name = "Stock Agent (MCP)"
37
+ self.description = "Stock market data and analysis expert using Alpha Vantage MCP Server"
38
+ self.mcp_client: Optional[MultiServerMCPClient] = None
39
+ self.model: Optional[ChatGoogleGenerativeAI] = None
40
+ self.model_with_tools = None
41
+ self.tools: List[BaseTool] = []
42
+ self.tool_map: Dict[str, BaseTool] = {}
43
+
44
+ async def initialize(self) -> None:
45
+ """Initialize the agent with Alpha Vantage MCP Server."""
46
+ print(f"πŸ“§ Initializing {self.name}...")
47
+
48
+ try:
49
+ # Connect to Alpha Vantage MCP Server
50
+ print(f" πŸ“‘ Connecting to Alpha Vantage MCP Server...")
51
+
52
+ connection_name = "alphavantage"
53
+ connections: Dict[str, Dict[str, Any]] = {}
54
+
55
+ api_key = (config.ALPHA_VANTAGE_API_KEY or "").strip()
56
+ if not api_key:
57
+ raise ValueError(
58
+ "ALPHA_VANTAGE_API_KEY not configured in .env file.\n"
59
+ "Get your free API key from: https://www.alphavantage.co/support/#api-key"
60
+ )
61
+
62
+ print(" Using Alpha Vantage local MCP server via uvx...")
63
+
64
+ # Try to find uvx executable (uv's command runner)
65
+ try:
66
+ uvx_executable = _resolve_executable(["uvx.exe", "uvx.cmd", "uvx"])
67
+ print(f" Found uvx at: {uvx_executable}")
68
+ except FileNotFoundError:
69
+ raise ValueError(
70
+ "uvx not found. Please install uv (Python package manager) from:\n"
71
+ " Windows: pip install uv\n"
72
+ " Or visit: https://docs.astral.sh/uv/getting-started/installation/\n"
73
+ "After installing, restart your terminal/IDE."
74
+ )
75
+
76
+ # Alpha Vantage MCP uses stdio transport with uvx av-mcp
77
+ connections[connection_name] = {
78
+ "transport": "stdio",
79
+ "command": uvx_executable,
80
+ "args": ["av-mcp", api_key],
81
+ }
82
+
83
+ self.mcp_client = MultiServerMCPClient(connections)
84
+
85
+ # Load MCP tools as LangChain tools
86
+ print(" Loading tools from Alpha Vantage MCP Server...")
87
+ all_tools = await self.mcp_client.get_tools(server_name=connection_name)
88
+ if not all_tools:
89
+ raise RuntimeError("No tools available from Alpha Vantage MCP Server")
90
+
91
+ # Exclude REALTIME_BULK_QUOTES tool
92
+ self.tools = [tool for tool in all_tools if tool.name != "REALTIME_BULK_QUOTES"]
93
+ print(f" Excluded REALTIME_BULK_QUOTES tool")
94
+
95
+ self.tool_map = {tool.name: tool for tool in self.tools}
96
+
97
+ # Initialize Gemini chat model bound to tools
98
+ self.model = ChatGoogleGenerativeAI(
99
+ model="gemini-2.5-flash",
100
+ temperature=0.1,
101
+ google_api_key=config.GOOGLE_API_KEY,
102
+ )
103
+ self.model_with_tools = self.model.bind_tools(self.tools)
104
+
105
+ print(f" βœ… Connected to Alpha Vantage MCP Server with {len(self.tools)} tools")
106
+ print(f" πŸ“‹ Available tool categories:")
107
+
108
+ # Group tools by category for better display
109
+ tool_names = [tool.name for tool in self.tools]
110
+ print(f" - Core Stock APIs: TIME_SERIES_*, GLOBAL_QUOTE, SYMBOL_SEARCH, etc.")
111
+ print(f" - Fundamental Data: COMPANY_OVERVIEW, INCOME_STATEMENT, etc.")
112
+ print(f" - Alpha Intelligence: NEWS_SENTIMENT, TOP_GAINERS_LOSERS, etc.")
113
+ print(f" - Technical Indicators: RSI, MACD, SMA, EMA, etc.")
114
+ print(f" Total: {len(self.tools)} tools available")
115
+
116
+ print(f" βœ… {self.name} ready!")
117
+
118
+ except Exception as e:
119
+ import traceback
120
+ print(f" ❌ Error initializing {self.name}: {e}")
121
+ print(f" πŸ“‹ Full error details:")
122
+ traceback.print_exc()
123
+ raise
124
+
125
+ async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
126
+ """Process a query using Alpha Vantage MCP Server tools."""
127
+ try:
128
+ print(f"\nπŸ“ˆ {self.name} processing: '{query}'")
129
+ messages: List[Any] = []
130
+ if history:
131
+ trimmed_history = history[-10:]
132
+ for turn in trimmed_history:
133
+ user_text = turn.get("user")
134
+ if user_text:
135
+ messages.append(HumanMessage(content=user_text))
136
+ assistant_text = turn.get("assistant")
137
+ if assistant_text:
138
+ messages.append(AIMessage(content=assistant_text))
139
+ messages.append(HumanMessage(content=query))
140
+ final_response = ""
141
+ tool_calls_info = []
142
+
143
+ while True:
144
+ if not self.model_with_tools:
145
+ raise RuntimeError("Model not initialized with tools")
146
+
147
+ ai_message = await self.model_with_tools.ainvoke(messages)
148
+ messages.append(ai_message)
149
+
150
+ tool_calls = getattr(ai_message, "tool_calls", [])
151
+ if not tool_calls:
152
+ final_response = _to_text(ai_message.content)
153
+ break
154
+
155
+ for call in tool_calls:
156
+ tool_name = call.get("name")
157
+ tool_args = call.get("args", {})
158
+ tool_call_id = call.get("id")
159
+ print(f" πŸ”§ MCP Tool call: {tool_name}({tool_args})")
160
+ tool_calls_info.append(f"πŸ”§ MCP Tool call: {tool_name}({tool_args})")
161
+
162
+ tool = self.tool_map.get(tool_name)
163
+ if not tool:
164
+ tool_result = {"error": f"Tool '{tool_name}' not found"}
165
+ else:
166
+ tool_result = await tool.ainvoke(tool_args)
167
+
168
+ messages.append(
169
+ ToolMessage(
170
+ content=_to_text(tool_result),
171
+ tool_call_id=tool_call_id or "",
172
+ )
173
+ )
174
+
175
+ return {
176
+ "success": True,
177
+ "agent": self.name,
178
+ "response": final_response,
179
+ "query": query,
180
+ "tool_calls": tool_calls_info
181
+ }
182
+
183
+ except Exception as e:
184
+ print(f" ❌ Error in {self.name}: {e}")
185
+ import traceback
186
+ traceback.print_exc()
187
+ return {
188
+ "success": False,
189
+ "agent": self.name,
190
+ "error": str(e),
191
+ "query": query
192
+ }
193
+
194
+ async def cleanup(self) -> None:
195
+ """Cleanup resources."""
196
+ print(f"🧹 {self.name} cleaned up")
src/api/__init__.py ADDED
File without changes
src/api/main.py ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application exposing multi-agent system via REST API."""
2
+ import asyncio
3
+ import json
4
+ from typing import List, Dict, Optional
5
+ from fastapi import FastAPI, UploadFile, File, HTTPException, WebSocket, WebSocketDisconnect
6
+ from fastapi.responses import StreamingResponse
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from pydantic import BaseModel, Field
9
+ from pathlib import Path
10
+ import tempfile
11
+ import os
12
+
13
+ from src.agents.crypto_agent_mcp import CryptoAgentMCP
14
+ from src.agents.rag_agent_mcp import RAGAgentMCP
15
+ from src.agents.stock_agent_mcp import StockAgentMCP
16
+ from src.agents.search_agent_mcp import SearchAgentMCP
17
+ from src.agents.finance_tracker_agent_mcp import FinanceTrackerMCP
18
+ from src.core.langgraph_supervisor import ReActSupervisor
19
+
20
+
21
+ # ============================================================================
22
+ # Pydantic Models
23
+ # ============================================================================
24
+
25
+ class ChatMessage(BaseModel):
26
+ """Chat message model."""
27
+ role: str = Field(..., description="Role: 'user' or 'assistant'")
28
+ content: str = Field(..., description="Message content")
29
+
30
+
31
+ class ChatRequest(BaseModel):
32
+ """Request model for chat endpoint."""
33
+ message: str = Field(..., description="User's query message")
34
+ history: Optional[List[ChatMessage]] = Field(default=[], description="Chat history")
35
+
36
+
37
+ class ChatResponse(BaseModel):
38
+ """Response model for non-streaming chat."""
39
+ response: str = Field(..., description="Assistant's response")
40
+ history: List[ChatMessage] = Field(..., description="Updated chat history")
41
+
42
+
43
+ class StreamEvent(BaseModel):
44
+ """Streaming event model."""
45
+ type: str = Field(..., description="Event type: thinking, action, observation, final_start, final_token, final_complete, error")
46
+ data: Dict = Field(default={}, description="Event data")
47
+
48
+
49
+ class UploadResponse(BaseModel):
50
+ """Response model for document upload."""
51
+ success: bool = Field(..., description="Upload success status")
52
+ message: str = Field(..., description="Status message")
53
+ details: Optional[Dict] = Field(default=None, description="Upload details")
54
+
55
+
56
+ class HealthResponse(BaseModel):
57
+ """Health check response."""
58
+ status: str = Field(..., description="System status")
59
+ initialized: bool = Field(..., description="Whether system is initialized")
60
+ agents: Dict[str, bool] = Field(..., description="Status of each agent")
61
+
62
+
63
+ # ============================================================================
64
+ # Multi-Agent Application
65
+ # ============================================================================
66
+
67
+ class MultiAgentApp:
68
+ """Main application orchestrating LLM supervisor and agents."""
69
+
70
+ def __init__(self):
71
+ self.crypto_agent = CryptoAgentMCP()
72
+ self.rag_agent = RAGAgentMCP()
73
+ self.stock_agent = StockAgentMCP()
74
+ self.search_agent = SearchAgentMCP()
75
+ self.finance_tracker = FinanceTrackerMCP()
76
+ self.supervisor = None
77
+ self.chat_history: List[Dict[str, str]] = []
78
+ self.initialized = False
79
+
80
+ async def initialize(self):
81
+ """Initialize all agents and supervisor with parallel initialization."""
82
+ if not self.initialized:
83
+ print("πŸš€ Initializing Multi-Agent System...")
84
+ print("⚑ Using parallel initialization for faster startup...")
85
+
86
+ # Initialize all agents in parallel
87
+ init_tasks = [
88
+ self.crypto_agent.initialize(),
89
+ self.rag_agent.initialize(),
90
+ self.stock_agent.initialize(),
91
+ self.search_agent.initialize(),
92
+ self.finance_tracker.initialize()
93
+ ]
94
+
95
+ # Execute all initializations concurrently
96
+ results = await asyncio.gather(*init_tasks, return_exceptions=True)
97
+
98
+ # Check for initialization failures
99
+ failed_agents = []
100
+ agent_names = ["Crypto", "RAG", "Stock", "Search", "Finance Tracker"]
101
+ for i, result in enumerate(results):
102
+ if isinstance(result, Exception):
103
+ print(f" ⚠️ {agent_names[i]} agent initialization failed: {result}")
104
+ failed_agents.append(agent_names[i])
105
+
106
+ if failed_agents:
107
+ print(f" ⚠️ Some agents failed to initialize: {', '.join(failed_agents)}")
108
+ print(" ℹ️ System will continue with available agents")
109
+
110
+ # Initialize supervisor with agent references
111
+ self.supervisor = ReActSupervisor(
112
+ crypto_agent=self.crypto_agent,
113
+ rag_agent=self.rag_agent,
114
+ stock_agent=self.stock_agent,
115
+ search_agent=self.search_agent,
116
+ finance_tracker=self.finance_tracker
117
+ )
118
+
119
+ self.initialized = True
120
+ print("βœ… System initialized with LangGraph supervisor!")
121
+ return "βœ… All agents initialized and ready!"
122
+
123
+ async def process_query_streaming(self, message: str, history: List[Dict[str, str]]):
124
+ """
125
+ Process user query with streaming updates.
126
+
127
+ Args:
128
+ message: User's input message
129
+ history: Chat history in internal format [{"user": "...", "assistant": "..."}]
130
+
131
+ Yields:
132
+ Dictionary updates from supervisor
133
+ """
134
+ if not message.strip():
135
+ yield {"type": "error", "error": "Please enter a query."}
136
+ return
137
+
138
+ try:
139
+ # Check if system is initialized
140
+ if not self.initialized:
141
+ yield {"type": "error", "error": "System not initialized."}
142
+ return
143
+
144
+ # Stream updates from supervisor
145
+ async for update in self.supervisor.process_streaming(message, history=history):
146
+ yield update
147
+
148
+ # Update chat history
149
+ self.chat_history.append({"user": message})
150
+ if len(self.chat_history) > 20:
151
+ self.chat_history = self.chat_history[-20:]
152
+
153
+ except Exception as e:
154
+ yield {"type": "error", "error": str(e)}
155
+
156
+ async def upload_document(self, file_path: str, filename: str) -> Dict:
157
+ """
158
+ Handle document upload to ChromaDB Cloud.
159
+
160
+ Args:
161
+ file_path: Path to the uploaded file
162
+ filename: Original filename
163
+
164
+ Returns:
165
+ Upload result dictionary
166
+ """
167
+ try:
168
+ if not self.initialized:
169
+ return {
170
+ "success": False,
171
+ "error": "System not initialized"
172
+ }
173
+
174
+ # Validate file exists
175
+ if not os.path.exists(file_path):
176
+ return {
177
+ "success": False,
178
+ "error": f"File not found: {file_path}"
179
+ }
180
+
181
+ # Validate file type
182
+ file_extension = Path(filename).suffix.lower()
183
+ if file_extension not in ['.pdf', '.txt', '.docx']:
184
+ return {
185
+ "success": False,
186
+ "error": f"Unsupported file type: {file_extension}. Supported: PDF, TXT, DOCX"
187
+ }
188
+
189
+ # Upload to RAG agent
190
+ result = await self.rag_agent.add_document(file_path)
191
+
192
+ return result
193
+
194
+ except Exception as e:
195
+ return {
196
+ "success": False,
197
+ "error": str(e)
198
+ }
199
+
200
+ async def cleanup(self):
201
+ """Cleanup resources."""
202
+ if self.initialized:
203
+ await self.crypto_agent.cleanup()
204
+ await self.rag_agent.cleanup()
205
+ await self.stock_agent.cleanup()
206
+ await self.search_agent.cleanup()
207
+ await self.finance_tracker.cleanup()
208
+ print("🧹 Cleanup complete")
209
+ self.chat_history.clear()
210
+
211
+ def get_agent_status(self) -> Dict[str, bool]:
212
+ """Get status of all agents."""
213
+ return {
214
+ "crypto": self.crypto_agent is not None,
215
+ "rag": self.rag_agent is not None,
216
+ "stock": self.stock_agent is not None,
217
+ "search": self.search_agent is not None,
218
+ "finance_tracker": self.finance_tracker is not None
219
+ }
220
+
221
+
222
+ # ============================================================================
223
+ # FastAPI Application
224
+ # ============================================================================
225
+
226
+ app = FastAPI(
227
+ title="Multi-Agent Assistant API",
228
+ description="FastAPI interface for multi-agent LLM system with ReAct supervisor",
229
+ version="1.0.0"
230
+ )
231
+
232
+ # Add CORS middleware
233
+ app.add_middleware(
234
+ CORSMiddleware,
235
+ allow_origins=["*"], # Adjust in production
236
+ allow_credentials=True,
237
+ allow_methods=["*"],
238
+ allow_headers=["*"],
239
+ )
240
+
241
+ # Initialize multi-agent app
242
+ multi_agent_app = MultiAgentApp()
243
+
244
+
245
+ # ============================================================================
246
+ # Startup/Shutdown Events
247
+ # ============================================================================
248
+
249
+ @app.on_event("startup")
250
+ async def startup_event():
251
+ """Initialize system on startup."""
252
+ print("=" * 60)
253
+ print("πŸš€ Starting FastAPI Multi-Agent Assistant")
254
+ print("=" * 60)
255
+
256
+ # Validate configuration
257
+ try:
258
+ from src.core.config import config
259
+ config.validate()
260
+ print("βœ… Configuration validated")
261
+ except ValueError as e:
262
+ print(f"❌ Configuration Error: {e}")
263
+ raise
264
+
265
+ # Initialize all agents
266
+ await multi_agent_app.initialize()
267
+ print("=" * 60)
268
+
269
+
270
+ @app.on_event("shutdown")
271
+ async def shutdown_event():
272
+ """Cleanup on shutdown."""
273
+ print("\nπŸ›‘ Shutting down...")
274
+ await multi_agent_app.cleanup()
275
+ print("πŸ‘‹ Goodbye!")
276
+
277
+
278
+ # ============================================================================
279
+ # API Endpoints
280
+ # ============================================================================
281
+
282
+ @app.get("/", tags=["Root"])
283
+ async def root():
284
+ """Root endpoint."""
285
+ return {
286
+ "message": "Multi-Agent Assistant API",
287
+ "version": "1.0.0",
288
+ "docs": "/docs",
289
+ "health": "/health"
290
+ }
291
+
292
+
293
+ @app.get("/health", response_model=HealthResponse, tags=["Health"])
294
+ async def health_check():
295
+ """
296
+ Health check endpoint.
297
+
298
+ Returns system status and agent availability.
299
+ """
300
+ return HealthResponse(
301
+ status="healthy" if multi_agent_app.initialized else "initializing",
302
+ initialized=multi_agent_app.initialized,
303
+ agents=multi_agent_app.get_agent_status()
304
+ )
305
+
306
+
307
+ @app.post("/api/v1/chat/stream", tags=["Chat"])
308
+ async def stream_chat(request: ChatRequest):
309
+ """
310
+ Stream chat responses with intermediate reasoning steps.
311
+
312
+ Uses Server-Sent Events (SSE) to stream updates in real-time.
313
+
314
+ Args:
315
+ request: ChatRequest with message and optional history
316
+
317
+ Returns:
318
+ StreamingResponse with SSE events
319
+ """
320
+ # Convert ChatMessage models to internal format
321
+ internal_history = []
322
+ for msg in request.history:
323
+ if msg.role == "user":
324
+ internal_history.append({"user": msg.content})
325
+ elif msg.role == "assistant":
326
+ internal_history.append({"assistant": msg.content})
327
+
328
+ async def event_generator():
329
+ """Generate SSE events with explicit flushing."""
330
+ try:
331
+ async for update in multi_agent_app.process_query_streaming(
332
+ request.message,
333
+ internal_history
334
+ ):
335
+ # Format as SSE with explicit newlines
336
+ event_data = json.dumps(update)
337
+ yield f"data: {event_data}\n\n"
338
+ # Force flush by yielding empty string (triggers send)
339
+ await asyncio.sleep(0) # Allow event loop to process
340
+ except Exception as e:
341
+ error_event = json.dumps({"type": "error", "error": str(e)})
342
+ yield f"data: {error_event}\n\n"
343
+
344
+ return StreamingResponse(
345
+ event_generator(),
346
+ media_type="text/event-stream",
347
+ headers={
348
+ "Cache-Control": "no-cache",
349
+ "Connection": "keep-alive",
350
+ "X-Accel-Buffering": "no", # Disable nginx buffering
351
+ }
352
+ )
353
+
354
+
355
+ @app.websocket("/ws/v1/chat")
356
+ async def websocket_chat(websocket: WebSocket):
357
+ """
358
+ WebSocket endpoint for chat with streaming updates.
359
+
360
+ Client sends: {"message": "query", "history": [...]}
361
+ Server streams: {"type": "...", "data": {...}}
362
+ """
363
+ await websocket.accept()
364
+
365
+ try:
366
+ while True:
367
+ # Receive message from client
368
+ data = await websocket.receive_json()
369
+ message = data.get("message", "")
370
+ history = data.get("history", [])
371
+
372
+ # Convert to internal format
373
+ internal_history = []
374
+ for msg in history:
375
+ if msg.get("role") == "user":
376
+ internal_history.append({"user": msg.get("content", "")})
377
+ elif msg.get("role") == "assistant":
378
+ internal_history.append({"assistant": msg.get("content", "")})
379
+
380
+ # Stream responses
381
+ async for update in multi_agent_app.process_query_streaming(message, internal_history):
382
+ await websocket.send_json(update)
383
+
384
+ except WebSocketDisconnect:
385
+ print("WebSocket client disconnected")
386
+ except Exception as e:
387
+ print(f"WebSocket error: {e}")
388
+ await websocket.close(code=1011, reason=str(e))
389
+
390
+
391
+ @app.post("/api/v1/documents/upload", response_model=UploadResponse, tags=["Documents"])
392
+ async def upload_document(file: UploadFile = File(...)):
393
+ """
394
+ Upload a document to the RAG agent.
395
+
396
+ Supported file types: PDF, TXT, DOCX
397
+
398
+ Args:
399
+ file: Uploaded file
400
+
401
+ Returns:
402
+ UploadResponse with status and details
403
+ """
404
+ # Save uploaded file temporarily
405
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp_file:
406
+ content = await file.read()
407
+ tmp_file.write(content)
408
+ tmp_file_path = tmp_file.name
409
+
410
+ try:
411
+ # Process upload
412
+ result = await multi_agent_app.upload_document(tmp_file_path, file.filename)
413
+
414
+ if result.get("success"):
415
+ return UploadResponse(
416
+ success=True,
417
+ message="Document uploaded successfully",
418
+ details={
419
+ "filename": result.get("filename"),
420
+ "file_type": result.get("file_type"),
421
+ "chunks_added": result.get("chunks_added"),
422
+ "total_documents": result.get("total_documents")
423
+ }
424
+ )
425
+ else:
426
+ raise HTTPException(
427
+ status_code=400,
428
+ detail=result.get("error", "Upload failed")
429
+ )
430
+
431
+ finally:
432
+ # Clean up temp file
433
+ if os.path.exists(tmp_file_path):
434
+ os.unlink(tmp_file_path)
435
+
436
+
437
+ # ============================================================================
438
+ # Main Entry Point
439
+ # ============================================================================
440
+
441
+ if __name__ == "__main__":
442
+ import uvicorn
443
+
444
+ uvicorn.run(
445
+ "api:app",
446
+ host="0.0.0.0",
447
+ port=8000,
448
+ reload=True,
449
+ log_level="info"
450
+ )
src/core/__init__.py ADDED
File without changes
src/core/config.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for multi-agent RAG system."""
2
+ import os
3
+ from typing import Optional
4
+ from dataclasses import dataclass
5
+ from dotenv import load_dotenv
6
+
7
+ # Load environment variables from .env file
8
+ load_dotenv()
9
+
10
+
11
+ @dataclass
12
+ class Config:
13
+ """Application configuration."""
14
+
15
+ # API Keys
16
+ GOOGLE_API_KEY: Optional[str] = None
17
+ COINGECKO_API_KEY: Optional[str] = None
18
+ ALPHA_VANTAGE_API_KEY: Optional[str] = None
19
+ # ChromaDB Cloud Configuration
20
+ CHROMA_API_KEY: Optional[str] = None
21
+ CHROMA_TENANT: Optional[str] = None
22
+ CHROMA_DATABASE: Optional[str] = None
23
+ CHROMA_CLOUD_HOST: str = "api.trychroma.com"
24
+
25
+ # ChromaDB Collection Names
26
+ DOCUMENTS_COLLECTION: str = "test-embed"
27
+
28
+ # Embedding Configuration
29
+ EMBEDDING_FUNCTION: str = "default" # Options: default, openai, cohere, jina, voyageai
30
+
31
+ # Google Cloud SQL Configuration
32
+ CLOUD_SQL_INSTANCE_CONNECTION_NAME: Optional[str] = None # Format: project:region:instance
33
+ CLOUD_SQL_DB_USER: Optional[str] = None
34
+ CLOUD_SQL_DB_PASS: Optional[str] = None
35
+ CLOUD_SQL_DB_NAME: Optional[str] = None
36
+ CLOUD_SQL_PRIVATE_IP: bool = False # Set to True to use private IP
37
+
38
+ # MCP Toolbox Configuration (for Finance Tracker Agent)
39
+ GCP_PROJECT_ID: Optional[str] = None # Google Cloud Project ID
40
+ CLOUD_SQL_REGION: Optional[str] = None # e.g., us-central1
41
+ CLOUD_SQL_INSTANCE: Optional[str] = None # Instance name only (not full connection string)
42
+ MCP_TOOLBOX_SERVER_URL: str = "http://localhost:5000" # MCP Toolbox HTTP server URL
43
+
44
+ # CoinGecko MCP Server Configuration
45
+ COINGECKO_MCP_URL: str = "https://mcp.pro-api.coingecko.com/mcp"
46
+ COINGECKO_MCP_AUTH_URL: str = "https://mcp.pro-api.coingecko.com/auth"
47
+
48
+ # UI Configuration
49
+ UI_PORT: int = 7860
50
+ UI_HOST: str = "0.0.0.0"
51
+ MAX_FILE_SIZE_MB: int = 50
52
+ ALLOWED_FILE_TYPES: list = None
53
+
54
+ def __post_init__(self):
55
+ """Initialize mutable default values."""
56
+ if self.ALLOWED_FILE_TYPES is None:
57
+ self.ALLOWED_FILE_TYPES = [".pdf", ".txt", ".docx"]
58
+
59
+ @classmethod
60
+ def from_env(cls) -> "Config":
61
+ """Load configuration from environment variables."""
62
+ return cls(
63
+ ALPHA_VANTAGE_API_KEY=os.getenv("ALPHA_VANTAGE_API_KEY"),
64
+ GOOGLE_API_KEY=os.getenv("GOOGLE_API_KEY"),
65
+ COINGECKO_API_KEY=os.getenv("COINGECKO_API_KEY"),
66
+ COINGECKO_MCP_URL=os.getenv(
67
+ "COINGECKO_MCP_URL",
68
+ "https://mcp.pro-api.coingecko.com/mcp"
69
+ ),
70
+ COINGECKO_MCP_AUTH_URL=os.getenv(
71
+ "COINGECKO_MCP_AUTH_URL",
72
+ "https://mcp.pro-api.coingecko.com/auth"
73
+ ),
74
+ CHROMA_API_KEY=os.getenv("CHROMA_API_KEY"),
75
+ CHROMA_TENANT=os.getenv("CHROMA_TENANT"),
76
+ CHROMA_DATABASE=os.getenv("CHROMA_DATABASE"),
77
+ CHROMA_CLOUD_HOST=os.getenv("CHROMA_CLOUD_HOST", "api.trychroma.com"),
78
+ EMBEDDING_FUNCTION=os.getenv("CHROMA_EMBEDDING_FUNCTION", "default"),
79
+ DOCUMENTS_COLLECTION=os.getenv("DOCUMENTS_COLLECTION", "mcp-test"),
80
+ MAX_FILE_SIZE_MB=int(os.getenv("MAX_FILE_SIZE_MB", "50")),
81
+ CLOUD_SQL_INSTANCE_CONNECTION_NAME=os.getenv("CLOUD_SQL_INSTANCE_CONNECTION_NAME"),
82
+ CLOUD_SQL_DB_USER=os.getenv("CLOUD_SQL_DB_USER"),
83
+ CLOUD_SQL_DB_PASS=os.getenv("CLOUD_SQL_DB_PASS"),
84
+ CLOUD_SQL_DB_NAME=os.getenv("CLOUD_SQL_DB_NAME", "finance_tracker"),
85
+ CLOUD_SQL_PRIVATE_IP=os.getenv("CLOUD_SQL_PRIVATE_IP", "False").lower() == "true",
86
+ GCP_PROJECT_ID=os.getenv("GCP_PROJECT_ID"),
87
+ CLOUD_SQL_REGION=os.getenv("CLOUD_SQL_REGION"),
88
+ CLOUD_SQL_INSTANCE=os.getenv("CLOUD_SQL_INSTANCE"),
89
+ MCP_TOOLBOX_SERVER_URL=os.getenv("MCP_TOOLBOX_SERVER_URL", "http://localhost:5000"),
90
+ )
91
+
92
+ def validate(self) -> None:
93
+ """Validate required configuration."""
94
+ errors = []
95
+
96
+ if not self.GOOGLE_API_KEY:
97
+ errors.append("GOOGLE_API_KEY environment variable is required")
98
+
99
+ if not self.CHROMA_API_KEY:
100
+ errors.append("CHROMA_API_KEY environment variable is required for ChromaDB Cloud")
101
+
102
+ if not self.CHROMA_TENANT:
103
+ errors.append("CHROMA_TENANT environment variable is required for ChromaDB Cloud")
104
+
105
+ if not self.CHROMA_DATABASE:
106
+ errors.append("CHROMA_DATABASE environment variable is required for ChromaDB Cloud")
107
+
108
+ if not self.ALPHA_VANTAGE_API_KEY:
109
+ raise ValueError(
110
+ "ALPHA_VANTAGE_API_KEY not configured. "
111
+ "Add it to your .env file. "
112
+ "Get your API key from: https://www.alphavantage.co/support/#api-key"
113
+ )
114
+ if errors:
115
+ raise ValueError(
116
+ "Configuration validation failed:\n" + "\n".join(f" - {e}" for e in errors)
117
+ )
118
+
119
+
120
+ # Global config instance
121
+ config = Config.from_env()
src/core/langgraph_supervisor.py ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional, TypedDict, Annotated, Sequence, AsyncGenerator
2
+ import operator
3
+
4
+ import asyncio
5
+ from asyncio import Queue
6
+
7
+ from langgraph.graph import StateGraph, END
8
+ from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
9
+ from langchain_google_genai import ChatGoogleGenerativeAI
10
+
11
+
12
+ from src.core.config import config
13
+
14
+
15
+ class AgentState(TypedDict):
16
+ """State for the ReAct agent pattern."""
17
+ messages: Annotated[Sequence[BaseMessage], operator.add]
18
+ query: str
19
+ agent_outputs: Dict[str, Any]
20
+ reasoning_steps: List[str]
21
+ final_answer: Optional[str]
22
+ current_step: int
23
+ max_steps: int
24
+
25
+
26
+ class ReActSupervisor:
27
+ """Supervisor using ReAct pattern for multi-agent orchestration with streaming."""
28
+
29
+ def __init__(
30
+ self,
31
+ crypto_agent=None,
32
+ rag_agent=None,
33
+ stock_agent=None,
34
+ search_agent=None,
35
+ finance_tracker=None,
36
+ max_steps: int = 5
37
+ ):
38
+ """
39
+ Initialize the ReAct supervisor.
40
+
41
+ Args:
42
+ crypto_agent: Crypto agent instance
43
+ rag_agent: RAG agent instance
44
+ stock_agent: Stock agent instance
45
+ search_agent: Web search agent instance (DuckDuckGo)
46
+ finance_tracker: Finance tracker agent instance
47
+ max_steps: Maximum reasoning steps before forcing completion
48
+ """
49
+ self.crypto_agent = crypto_agent
50
+ self.rag_agent = rag_agent
51
+ self.stock_agent = stock_agent
52
+ self.search_agent = search_agent
53
+ self.finance_tracker = finance_tracker
54
+ self.max_steps = max_steps
55
+ self.streaming_callback = None # For streaming updates
56
+
57
+ # Initialize supervisor LLM with structured output
58
+ self.supervisor_llm = ChatGoogleGenerativeAI(
59
+ model="gemini-2.5-flash",
60
+ temperature=0.1,
61
+ google_api_key=config.GOOGLE_API_KEY,
62
+ )
63
+
64
+ # Build the ReAct workflow
65
+ self.graph = self._build_react_graph()
66
+ self.compiled_graph = self.graph.compile()
67
+
68
+ def _build_react_graph(self) -> StateGraph:
69
+ """Build the ReAct pattern graph."""
70
+
71
+ # Create the state graph
72
+ workflow = StateGraph(AgentState)
73
+
74
+ # Add nodes
75
+ workflow.add_node("think", self.think_node)
76
+ workflow.add_node("act_crypto", self.act_crypto_node)
77
+ workflow.add_node("act_rag", self.act_rag_node)
78
+ workflow.add_node("act_stock", self.act_stock_node)
79
+ workflow.add_node("act_search", self.act_search_node)
80
+ workflow.add_node("act_finance_tracker", self.act_finance_tracker_node)
81
+ workflow.add_node("observe", self.observe_node)
82
+ workflow.add_node("finish", self.finish_node)
83
+
84
+ # Set entry point
85
+ workflow.set_entry_point("think")
86
+
87
+ # Add routing from think node
88
+ workflow.add_conditional_edges(
89
+ "think",
90
+ self.route_from_thinking,
91
+ {
92
+ "crypto": "act_crypto",
93
+ "rag": "act_rag",
94
+ "stock": "act_stock",
95
+ "search": "act_search",
96
+ "finance_tracker": "act_finance_tracker",
97
+ "finish": "finish",
98
+ }
99
+ )
100
+
101
+ # Actions lead to observe
102
+ workflow.add_edge("act_crypto", "observe")
103
+ workflow.add_edge("act_rag", "observe")
104
+ workflow.add_edge("act_stock", "observe")
105
+ workflow.add_edge("act_search", "observe")
106
+ workflow.add_edge("act_finance_tracker", "observe")
107
+
108
+ # Observe leads back to think (or finish if max steps)
109
+ workflow.add_conditional_edges(
110
+ "observe",
111
+ self.should_continue,
112
+ {
113
+ "continue": "think",
114
+ "finish": "finish"
115
+ }
116
+ )
117
+
118
+ # Finish ends the graph
119
+ workflow.add_edge("finish", END)
120
+
121
+ return workflow
122
+
123
+ async def _emit_update(self, update: Dict[str, Any]):
124
+ """Emit streaming update if callback is set."""
125
+ if self.streaming_callback:
126
+ await self.streaming_callback(update)
127
+
128
+ async def think_node(self, state: AgentState) -> Dict[str, Any]:
129
+ """Reasoning step: Analyze current state and decide next action."""
130
+ current_step = state.get("current_step", 0) + 1
131
+
132
+ # Build context from previous outputs
133
+ context = self._build_context(state)
134
+
135
+ # Create reasoning prompt
136
+ think_prompt = f"""You are a ReAct supervisor orchestrating multiple agents to answer user queries.
137
+
138
+ Current Query: {state['query']}
139
+
140
+ Available Actions:
141
+ - CALL_CRYPTO: Get cryptocurrency market data, prices, trends
142
+ - CALL_STOCK: Get stock market data, company information, financial data
143
+ - CALL_FINANCE_TRACKER: Manage personal stock portfolio (add transactions, view positions, analyze performance, get portfolio news)
144
+ - CALL_RAG: Search and retrieve information from uploaded documents
145
+ - CALL_SEARCH: Search the web for current information, news, or general knowledge
146
+ - FINISH: Provide final answer (use when you have sufficient information)
147
+
148
+ Current Step: {current_step}/{self.max_steps}
149
+
150
+ Information Gathered So Far:
151
+ {context}
152
+
153
+ IMPORTANT INSTRUCTIONS:
154
+ 1. Check what information you ALREADY HAVE in the context above
155
+ 2. Do NOT call the same agent twice unless you need different information
156
+ 3. If you already have an answer from any agent, move to FINISH
157
+ 4. Only call another agent if you need ADDITIONAL different information
158
+ 5. Use CALL_SEARCH for general knowledge, current events, and news
159
+ 6. FINISH when you have enough information to answer the user's query
160
+
161
+ Based on what you know so far, reason about what to do next.
162
+ Format your response as:
163
+
164
+ THOUGHT: [Analyze what you have and what you still need]
165
+ ACTION: [CALL_CRYPTO | CALL_STOCK | CALL_RAG | CALL_SEARCH | FINISH]
166
+ JUSTIFICATION: [Why this action will help]"""
167
+
168
+ response = await self.supervisor_llm.ainvoke([
169
+ SystemMessage(content="You are a ReAct supervisor. Think step by step and avoid redundant actions."),
170
+ HumanMessage(content=think_prompt)
171
+ ])
172
+
173
+ # Parse the response
174
+ content = response.content
175
+ thought = ""
176
+ action = "finish"
177
+ justification = ""
178
+
179
+ if "THOUGHT:" in content:
180
+ thought = content.split("THOUGHT:")[1].split("ACTION:")[0].strip()
181
+
182
+ if "ACTION:" in content:
183
+ action_text = content.split("ACTION:")[1].split("\n")[0].strip().upper()
184
+ if "CRYPTO" in action_text:
185
+ action = "crypto"
186
+ elif "FINANCE_TRACKER" in action_text or "FINANCE" in action_text:
187
+ action = "finance_tracker"
188
+ elif "STOCK" in action_text:
189
+ action = "stock"
190
+ elif "RAG" in action_text:
191
+ action = "rag"
192
+ elif "SEARCH" in action_text:
193
+ action = "search"
194
+ else:
195
+ action = "finish"
196
+
197
+ if "JUSTIFICATION:" in content:
198
+ justification = content.split("JUSTIFICATION:")[1].strip()
199
+
200
+ # Add reasoning step
201
+ reasoning_steps = state.get("reasoning_steps", [])
202
+ reasoning_steps.append(f"Step {current_step}: {thought} -> Action: {action}")
203
+
204
+ print(f"\nThinking (Step {current_step}):")
205
+ print(f" Thought: {thought}")
206
+ print(f" Action: {action}")
207
+ print(f" Justification: {justification}")
208
+
209
+ # Emit streaming update
210
+ await self._emit_update({
211
+ "type": "thinking",
212
+ "step": current_step,
213
+ "thought": thought,
214
+ "action": action,
215
+ "justification": justification
216
+ })
217
+
218
+ return {
219
+ "current_step": current_step,
220
+ "reasoning_steps": reasoning_steps,
221
+ "messages": [AIMessage(content=f"Thought: {thought}\nAction: {action}")],
222
+ "next_action": action
223
+ }
224
+
225
+ async def act_crypto_node(self, state: AgentState) -> Dict[str, Any]:
226
+ """Execute crypto agent and return raw output."""
227
+ if not self.crypto_agent:
228
+ return {"agent_outputs": {"crypto_error": "Crypto agent not available"}}
229
+
230
+ print(" Calling Crypto Agent...")
231
+ await self._emit_update({"type": "action", "agent": "crypto"})
232
+
233
+ result = await self.crypto_agent.process(
234
+ state["query"],
235
+ history=self._extract_history(state["messages"])
236
+ )
237
+
238
+ agent_outputs = state.get("agent_outputs", {})
239
+ agent_outputs["crypto"] = result
240
+
241
+ return {"agent_outputs": agent_outputs}
242
+
243
+ async def act_rag_node(self, state: AgentState) -> Dict[str, Any]:
244
+ """Execute RAG agent and return raw output."""
245
+ if not self.rag_agent:
246
+ return {"agent_outputs": {"rag_error": "RAG agent not available"}}
247
+
248
+ print(" Calling RAG Agent...")
249
+ await self._emit_update({"type": "action", "agent": "rag"})
250
+
251
+ result = await self.rag_agent.process(
252
+ state["query"],
253
+ history=self._extract_history(state["messages"])
254
+ )
255
+
256
+ agent_outputs = state.get("agent_outputs", {})
257
+ agent_outputs["rag"] = result
258
+
259
+ return {"agent_outputs": agent_outputs}
260
+
261
+ async def act_stock_node(self, state: AgentState) -> Dict[str, Any]:
262
+ """Execute stock agent and return raw output."""
263
+ if not self.stock_agent:
264
+ return {"agent_outputs": {"stock_error": "Stock agent not available"}}
265
+
266
+ print(" Calling Stock Agent...")
267
+ await self._emit_update({"type": "action", "agent": "stock"})
268
+
269
+ result = await self.stock_agent.process(
270
+ state["query"],
271
+ history=self._extract_history(state["messages"])
272
+ )
273
+
274
+ agent_outputs = state.get("agent_outputs", {})
275
+ agent_outputs["stock"] = result
276
+
277
+ return {"agent_outputs": agent_outputs}
278
+
279
+ async def act_search_node(self, state: AgentState) -> Dict[str, Any]:
280
+ """Execute web search agent and return raw output."""
281
+ if not self.search_agent:
282
+ return {"agent_outputs": {"search_error": "Search agent not available"}}
283
+
284
+ print(" Calling Web Search Agent...")
285
+ await self._emit_update({"type": "action", "agent": "search"})
286
+
287
+ result = await self.search_agent.process(
288
+ state["query"],
289
+ history=self._extract_history(state["messages"])
290
+ )
291
+
292
+ agent_outputs = state.get("agent_outputs", {})
293
+ agent_outputs["search"] = result
294
+
295
+ return {"agent_outputs": agent_outputs}
296
+
297
+ async def act_finance_tracker_node(self, state: AgentState) -> Dict[str, Any]:
298
+ """Execute finance tracker agent and return raw output."""
299
+ if not self.finance_tracker:
300
+ return {"agent_outputs": {"finance_tracker_error": "Finance Tracker agent not available"}}
301
+
302
+ print(" Calling Finance Tracker Agent...")
303
+ await self._emit_update({"type": "action", "agent": "finance_tracker"})
304
+
305
+ result = await self.finance_tracker.process(
306
+ state["query"],
307
+ history=self._extract_history(state["messages"])
308
+ )
309
+
310
+ agent_outputs = state.get("agent_outputs", {})
311
+ agent_outputs["finance_tracker"] = result
312
+
313
+ return {"agent_outputs": agent_outputs}
314
+
315
+ async def observe_node(self, state: AgentState) -> Dict[str, Any]:
316
+ """Process and observe the latest agent output."""
317
+ agent_outputs = state.get("agent_outputs", {})
318
+
319
+ latest_output = "No new observations"
320
+ latest_agent = "unknown"
321
+ search_urls = None
322
+ tool_calls = None
323
+
324
+ if agent_outputs:
325
+ for agent_name, output in list(agent_outputs.items())[-1:]:
326
+ if isinstance(output, dict) and output.get("success"):
327
+ response = output.get('response', 'No response')
328
+ latest_output = response
329
+ latest_agent = agent_name
330
+ # Extract search URLs if available (from search agent)
331
+ if "search_urls" in output:
332
+ search_urls = output["search_urls"]
333
+ # Extract tool calls if available (from MCP agents)
334
+ if "tool_calls" in output:
335
+ tool_calls = output["tool_calls"]
336
+ break
337
+
338
+ # Prepend tool calls to the summary if available
339
+ summary = latest_output
340
+ if tool_calls:
341
+ tool_calls_text = "\n".join(tool_calls)
342
+ summary = f"{tool_calls_text}\n Observation from {latest_agent}: {latest_output}"
343
+
344
+ # Apply length limit
345
+ summary = summary[:2000] + "..." if len(summary) > 2000 else summary
346
+ print(f" Observation from {latest_agent}: {summary}")
347
+
348
+ # Emit streaming update with search URLs if available
349
+ update_data = {
350
+ "type": "observation",
351
+ "agent": latest_agent,
352
+ "summary": summary
353
+ }
354
+ if search_urls:
355
+ update_data["search_urls"] = search_urls
356
+
357
+ await self._emit_update(update_data)
358
+
359
+ return {
360
+ "messages": [AIMessage(content=f"Observation from {latest_agent}:\n{latest_output}")]
361
+ }
362
+
363
+ async def finish_node(self, state: AgentState) -> Dict[str, Any]:
364
+ """Synthesize all agent outputs and generate final answer with streaming."""
365
+ agent_outputs = state.get("agent_outputs", {})
366
+ reasoning_steps = state.get("reasoning_steps", [])
367
+
368
+ print("\nSupervisor Synthesizing Final Answer...")
369
+
370
+ # Build synthesis prompt with conversation history
371
+ synthesis_prompt = f"""You are synthesizing information to answer this query: {state['query']}
372
+
373
+ """
374
+
375
+ # Include conversation history for context
376
+ messages = state.get("messages", [])
377
+ if messages:
378
+ synthesis_prompt += "CONVERSATION HISTORY:\n"
379
+ for msg in messages:
380
+ if isinstance(msg, HumanMessage):
381
+ synthesis_prompt += f"User: {msg.content}\n"
382
+ elif isinstance(msg, AIMessage):
383
+ synthesis_prompt += f"Assistant: {msg.content}\n"
384
+ synthesis_prompt += "\n"
385
+
386
+ synthesis_prompt += f"""Your reasoning process:
387
+ {chr(10).join(reasoning_steps)}
388
+
389
+ Information gathered from agents:"""
390
+
391
+ for agent_name, output in agent_outputs.items():
392
+ if isinstance(output, dict) and output.get("success"):
393
+ synthesis_prompt += f"\n\n{agent_name.upper()} Agent Response:\n{output.get('response', 'No response')}"
394
+
395
+ synthesis_prompt += """
396
+
397
+ Now provide a comprehensive, well-structured answer that:
398
+ 1. Directly addresses the user's query (considering the conversation history if present)
399
+ 2. Integrates insights from all relevant agent outputs
400
+ 3. Is clear and actionable
401
+ 4. Highlights any important findings or recommendations
402
+ 5. Cites sources when appropriate
403
+
404
+ Final Answer:"""
405
+
406
+ # Emit start of final answer
407
+ await self._emit_update({
408
+ "type": "final_start"
409
+ })
410
+
411
+ # Stream the final answer token by token
412
+ # Stream the final answer token by token
413
+ final_answer = ""
414
+ async for chunk in self.supervisor_llm.astream([
415
+ SystemMessage(content="You are providing the final, synthesized answer."),
416
+ HumanMessage(content=synthesis_prompt)
417
+ ]):
418
+ if hasattr(chunk, 'content') and chunk.content:
419
+ # Clean unicode artifacts from Gemini streaming
420
+ clean_content = chunk.content.replace('βˆ—', '*')
421
+ final_answer += clean_content
422
+ # Emit each token/chunk as it arrives
423
+ await self._emit_update({
424
+ "type": "final_token",
425
+ "token": clean_content,
426
+ "accumulated": final_answer
427
+ })
428
+
429
+ # Emit completion of final answer
430
+ await self._emit_update({
431
+ "type": "final_complete",
432
+ "response": final_answer
433
+ })
434
+
435
+ return {
436
+ "final_answer": final_answer,
437
+ "messages": [AIMessage(content=final_answer)]
438
+ }
439
+
440
+ def route_from_thinking(self, state: AgentState) -> str:
441
+ """Route based on thinking decision."""
442
+ last_message = state["messages"][-1] if state["messages"] else None
443
+
444
+ if last_message and "Action:" in last_message.content:
445
+ try:
446
+ action_line = last_message.content.split("Action:")[1].split("\n")[0].strip().upper()
447
+
448
+ if "CRYPTO" in action_line or "CALL_CRYPTO" in action_line:
449
+ return "crypto"
450
+ elif "FINANCE_TRACKER" in action_line or "CALL_FINANCE_TRACKER" in action_line or "FINANCE" in action_line:
451
+ return "finance_tracker"
452
+ elif "STOCK" in action_line or "CALL_STOCK" in action_line:
453
+ return "stock"
454
+ elif "RAG" in action_line or "CALL_RAG" in action_line:
455
+ return "rag"
456
+ elif "SEARCH" in action_line or "CALL_SEARCH" in action_line:
457
+ return "search"
458
+ elif "FINISH" in action_line:
459
+ return "finish"
460
+ except (IndexError, AttributeError):
461
+ pass
462
+
463
+ return "finish"
464
+
465
+ def should_continue(self, state: AgentState) -> str:
466
+ """Decide whether to continue reasoning or finish."""
467
+ current_step = state.get("current_step", 0)
468
+
469
+ if current_step >= self.max_steps:
470
+ print(f" Max steps ({self.max_steps}) reached, finishing...")
471
+ return "finish"
472
+
473
+ return "continue"
474
+
475
+ def _build_context(self, state: AgentState) -> str:
476
+ """Build context string from current state, including conversation history."""
477
+ context_parts = []
478
+
479
+ # Include conversation history for context
480
+ messages = state.get("messages", [])
481
+ if messages:
482
+ history_text = "=== CONVERSATION HISTORY ===\n"
483
+ for msg in messages:
484
+ if isinstance(msg, HumanMessage):
485
+ history_text += f"User: {msg.content}\n"
486
+ elif isinstance(msg, AIMessage):
487
+ history_text += f"Assistant: {msg.content}\n"
488
+ context_parts.append(history_text)
489
+
490
+ # Include agent outputs from current query
491
+ agent_outputs = state.get("agent_outputs", {})
492
+ if agent_outputs:
493
+ for agent_name, output in agent_outputs.items():
494
+ if isinstance(output, dict) and output.get("success"):
495
+ response = output.get("response", "No response")
496
+ # Increased limit to provide more context to LLM
497
+ if len(response) > 5000:
498
+ response = response[:5000] + f"... [Response continues for {len(response)} total chars]"
499
+ context_parts.append(f"=== {agent_name.upper()} Agent ===\n{response}")
500
+
501
+ return "\n\n".join(context_parts) if context_parts else "No information gathered yet."
502
+
503
+ def _extract_history(self, messages: Sequence[BaseMessage]) -> List[Dict[str, str]]:
504
+ """Extract chat history from messages."""
505
+ history = []
506
+ for msg in messages:
507
+ if isinstance(msg, HumanMessage):
508
+ history.append({"user": msg.content})
509
+ elif isinstance(msg, AIMessage):
510
+ history.append({"assistant": msg.content})
511
+ return history[-10:]
512
+
513
+ async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
514
+ """Process query through ReAct supervisor pattern."""
515
+ initial_state: AgentState = {
516
+ "messages": [],
517
+ "query": query,
518
+ "agent_outputs": {},
519
+ "reasoning_steps": [],
520
+ "final_answer": None,
521
+ "current_step": 0,
522
+ "max_steps": self.max_steps
523
+ }
524
+
525
+ if history:
526
+ for turn in history[-3:]:
527
+ if "user" in turn:
528
+ initial_state["messages"].append(HumanMessage(content=turn["user"]))
529
+ if "assistant" in turn:
530
+ initial_state["messages"].append(AIMessage(content=turn["assistant"]))
531
+
532
+ try:
533
+ print(f"\nReAct Supervisor starting for query: '{query}'")
534
+ print(f" Max steps: {self.max_steps}")
535
+
536
+ final_state = await self.compiled_graph.ainvoke(initial_state)
537
+
538
+ return {
539
+ "success": True,
540
+ "response": final_state.get("final_answer", "No answer generated"),
541
+ "reasoning_steps": final_state.get("reasoning_steps", []),
542
+ "agent_outputs": final_state.get("agent_outputs", {}),
543
+ "steps_taken": final_state.get("current_step", 0)
544
+ }
545
+
546
+ except Exception as e:
547
+ print(f" ReAct Supervisor error: {e}")
548
+ return {
549
+ "success": False,
550
+ "error": str(e),
551
+ "response": f"Supervisor error: {str(e)}"
552
+ }
553
+
554
+ async def process_streaming(
555
+ self,
556
+ query: str,
557
+ history: Optional[List[Dict[str, str]]] = None
558
+ ) -> AsyncGenerator[Dict[str, Any], None]:
559
+ """
560
+ Process query with true async streaming using Queue.
561
+
562
+ Yields update dictionaries with types: thinking, action, observation, final, error
563
+ """
564
+ updates_queue = Queue()
565
+
566
+ async def callback(update: Dict[str, Any]):
567
+ """Non-blocking callback to queue updates."""
568
+ await updates_queue.put(update)
569
+
570
+ # Set streaming callback
571
+ self.streaming_callback = callback
572
+
573
+ # Start processing in background
574
+ result_task = asyncio.create_task(self.process(query, history))
575
+
576
+ # Stream updates efficiently without polling
577
+ try:
578
+ while not result_task.done():
579
+ try:
580
+ # Non-blocking wait with short timeout
581
+ update = await asyncio.wait_for(
582
+ updates_queue.get(),
583
+ timeout=0.01 # Very short timeout for responsiveness
584
+ )
585
+ yield update
586
+ except asyncio.TimeoutError:
587
+ # No update available yet, continue loop
588
+ continue
589
+ except Exception as e:
590
+ print(f"Warning: Error getting update from queue: {e}")
591
+ continue
592
+
593
+ # Drain any remaining updates from the queue
594
+ while not updates_queue.empty():
595
+ try:
596
+ update = await updates_queue.get()
597
+ yield update
598
+ except Exception as e:
599
+ print(f"Warning: Error draining queue: {e}")
600
+ break
601
+
602
+ # Handle final result
603
+ result = await result_task
604
+ if not result.get("success"):
605
+ yield {
606
+ "type": "error",
607
+ "error": result.get("error", "Unknown error")
608
+ }
609
+
610
+ except Exception as e:
611
+ yield {
612
+ "type": "error",
613
+ "error": str(e)
614
+ }
615
+ finally:
616
+ self.streaming_callback = None
src/core/mcp_client.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Proper MCP client using official Python SDK for MCP servers."""
2
+ import asyncio
3
+ import os
4
+ import sys
5
+ from copy import deepcopy
6
+ from pathlib import Path
7
+ from typing import Dict, Any, List, Optional
8
+ from mcp import ClientSession, StdioServerParameters
9
+ from mcp.client.stdio import stdio_client
10
+ from src.core.config import config
11
+
12
+
13
+ class ProperMCPClient:
14
+ """Base class for connecting to MCP servers using official SDK."""
15
+
16
+ def __init__(self, name: str, server_command: str, server_args: List[str], env: Optional[Dict[str, str]] = None):
17
+ """
18
+ Initialize MCP client.
19
+
20
+ Args:
21
+ name: Name of the MCP server
22
+ server_command: Command to start the MCP server (e.g., 'npx', 'uvx')
23
+ server_args: Arguments for the server command
24
+ env: Optional environment variables
25
+ """
26
+ self.name = name
27
+ self.server_command = server_command
28
+ self.server_args = server_args
29
+ self.env = env or {}
30
+ self.session: Optional[ClientSession] = None
31
+ self.exit_stack = None
32
+ self.available_tools: List[Dict[str, Any]] = []
33
+
34
+ async def connect(self) -> bool:
35
+ """
36
+ Connect to the MCP server using stdio transport.
37
+
38
+ Returns:
39
+ True if connection successful
40
+ """
41
+ try:
42
+ # Prepare environment for subprocess (ensure venv scripts on PATH)
43
+ env_vars = os.environ.copy()
44
+ if sys.prefix:
45
+ scripts_dir = Path(sys.prefix) / ("Scripts" if os.name == "nt" else "bin")
46
+ if scripts_dir.exists():
47
+ current_path = env_vars.get("PATH", "")
48
+ env_vars["PATH"] = f"{scripts_dir}{os.pathsep}{current_path}" if current_path else str(scripts_dir)
49
+
50
+ if self.env:
51
+ env_vars.update(self.env)
52
+
53
+ # Create server parameters
54
+ server_params = StdioServerParameters(
55
+ command=self.server_command,
56
+ args=self.server_args,
57
+ env=env_vars
58
+ )
59
+
60
+ # Create stdio client and session
61
+ from contextlib import AsyncExitStack
62
+ self.exit_stack = AsyncExitStack()
63
+
64
+ # Connect to server via stdio
65
+ stdio_transport = await self.exit_stack.enter_async_context(
66
+ stdio_client(server_params)
67
+ )
68
+
69
+ read, write = stdio_transport
70
+ self.session = await self.exit_stack.enter_async_context(
71
+ ClientSession(read, write)
72
+ )
73
+
74
+ # Initialize the session
75
+ await self.session.initialize()
76
+
77
+ # List available tools
78
+ tools_result = await self.session.list_tools()
79
+ self.available_tools = tools_result.tools if hasattr(tools_result, 'tools') else []
80
+
81
+ print(f" βœ… Connected to {self.name} MCP Server")
82
+ print(f" πŸ“‹ Available tools: {len(self.available_tools)}")
83
+
84
+ # Print tool names for debugging
85
+ for tool in self.available_tools:
86
+ tool_name = tool.name if hasattr(tool, 'name') else str(tool)
87
+ print(f" - {tool_name}")
88
+
89
+ return True
90
+
91
+ except Exception as e:
92
+ print(f" ❌ Failed to connect to {self.name}: {e}")
93
+ import traceback
94
+ traceback.print_exc()
95
+ return False
96
+
97
+ async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
98
+ """
99
+ Call a tool on the MCP server.
100
+
101
+ Args:
102
+ tool_name: Name of the tool to call
103
+ arguments: Arguments for the tool
104
+
105
+ Returns:
106
+ Tool execution result
107
+ """
108
+ if not self.session:
109
+ raise RuntimeError(f"MCP client not connected. Call connect() first.")
110
+
111
+ try:
112
+ # Call the tool using the session
113
+ result = await self.session.call_tool(tool_name, arguments)
114
+
115
+ # Parse result
116
+ if hasattr(result, 'content'):
117
+ # Extract content from MCP response
118
+ content = []
119
+ for item in result.content:
120
+ if hasattr(item, 'text'):
121
+ content.append(item.text)
122
+ elif hasattr(item, 'data'):
123
+ content.append(item.data)
124
+
125
+ return {
126
+ "success": True,
127
+ "result": content[0] if len(content) == 1 else content
128
+ }
129
+
130
+ return {
131
+ "success": True,
132
+ "result": str(result)
133
+ }
134
+
135
+ except Exception as e:
136
+ return {
137
+ "success": False,
138
+ "error": f"Tool execution failed: {str(e)}"
139
+ }
140
+
141
+ def get_tools_for_gemini(self) -> List[Dict[str, Any]]:
142
+ """
143
+ Convert MCP tools to Gemini function declarations format.
144
+
145
+ Returns:
146
+ List of function declarations for Gemini
147
+ """
148
+ def sanitize_schema(node: Any) -> None:
149
+ if isinstance(node, dict):
150
+ node.pop("title", None)
151
+ any_of = node.pop("anyOf", None)
152
+ if any_of:
153
+ replacement = any_of[0] if isinstance(any_of, list) and any_of else None
154
+ if isinstance(replacement, dict):
155
+ # merge first option into current node
156
+ for key, value in replacement.items():
157
+ node.setdefault(key, deepcopy(value))
158
+ sanitize_schema(replacement)
159
+
160
+ for key, value in list(node.items()):
161
+ sanitize_schema(value)
162
+ elif isinstance(node, list):
163
+ for item in node:
164
+ sanitize_schema(item)
165
+
166
+ function_declarations = []
167
+
168
+ for tool in self.available_tools:
169
+ # Extract tool information
170
+ tool_name = tool.name if hasattr(tool, 'name') else str(tool)
171
+ tool_description = tool.description if hasattr(tool, 'description') else f"MCP tool: {tool_name}"
172
+
173
+ # Extract input schema
174
+ tool_schema = tool.inputSchema if hasattr(tool, 'inputSchema') else {}
175
+
176
+ if tool_schema:
177
+ if hasattr(tool_schema, "to_dict"):
178
+ parameters = tool_schema.to_dict()
179
+ elif isinstance(tool_schema, dict):
180
+ parameters = deepcopy(tool_schema)
181
+ else:
182
+ try:
183
+ parameters = deepcopy(tool_schema)
184
+ except Exception:
185
+ parameters = tool_schema
186
+ else:
187
+ parameters = {
188
+ "type": "object",
189
+ "properties": {}
190
+ }
191
+
192
+ sanitize_schema(parameters)
193
+
194
+ # Convert to Gemini format
195
+ function_decl = {
196
+ "name": tool_name,
197
+ "description": tool_description,
198
+ "parameters": parameters
199
+ }
200
+
201
+ function_declarations.append(function_decl)
202
+
203
+ return [{"function_declarations": function_declarations}]
204
+
205
+ async def cleanup(self) -> None:
206
+ """Close the MCP session."""
207
+ if self.exit_stack:
208
+ await self.exit_stack.aclose()
209
+ print(f" 🧹 {self.name} MCP client closed")
210
+
211
+
212
+ class ChromaMCPClient(ProperMCPClient):
213
+ """MCP client for Chroma MCP Server."""
214
+
215
+ def __init__(self):
216
+ """Initialize Chroma MCP client."""
217
+ # Prepare environment variables for Chroma Cloud
218
+ env = {
219
+ "CHROMA_CLIENT_TYPE": "cloud",
220
+ "CHROMA_TENANT": config.CHROMA_TENANT,
221
+ "CHROMA_DATABASE": config.CHROMA_DATABASE,
222
+ "CHROMA_API_KEY": config.CHROMA_API_KEY,
223
+ }
224
+
225
+ # Initialize with uvx command to run chroma-mcp
226
+ super().__init__(
227
+ name="Chroma",
228
+ server_command="uvx",
229
+ server_args=[
230
+ "chroma-mcp",
231
+ "--client-type", "cloud"
232
+ ],
233
+ env=env
234
+ )
235
+
236
+
237
+ class CoinGeckoMCPClient(ProperMCPClient):
238
+ """MCP client for CoinGecko MCP Server."""
239
+
240
+ def __init__(self):
241
+ """Initialize CoinGecko MCP client."""
242
+ # Prepare environment variables
243
+ env = {}
244
+ if config.COINGECKO_API_KEY:
245
+ env["COINGECKO_PRO_API_KEY"] = config.COINGECKO_API_KEY
246
+ env["COINGECKO_ENVIRONMENT"] = "pro"
247
+
248
+ # Initialize with npx command to run CoinGecko MCP
249
+ super().__init__(
250
+ name="CoinGecko",
251
+ server_command="npx",
252
+ server_args=[
253
+ "-y",
254
+ "@coingecko/coingecko-mcp"
255
+ ],
256
+ env=env if env else None
257
+ )
258
+
259
+
260
+ # Alternative: Use public CoinGecko MCP endpoint
261
+ class CoinGeckoPublicMCPClient(ProperMCPClient):
262
+ """MCP client for CoinGecko Public MCP Server."""
263
+
264
+ def __init__(self):
265
+ """Initialize CoinGecko Public MCP client."""
266
+ # Use the public endpoint via mcp-remote
267
+ super().__init__(
268
+ name="CoinGecko Public",
269
+ server_command="npx",
270
+ server_args=[
271
+ "mcp-remote",
272
+ "https://mcp.api.coingecko.com/sse"
273
+ ],
274
+ env=None
275
+ )
276
+
277
+ # Have to provide limits to the DuckDuckGo MCP server process to avoid excessive resource usage
278
+ class DuckDuckGoMCPClient(ProperMCPClient):
279
+ """MCP client for DuckDuckGo MCP Server."""
280
+
281
+ def __init__(self):
282
+ """Initialize DuckDuckGo MCP client."""
283
+ super().__init__(
284
+ name="DuckDuckGo",
285
+ server_command="uvx",
286
+ server_args=[
287
+ "duckduckgo-mcp-server"
288
+ ],
289
+ env=None
290
+ )
src/utils/__init__.py ADDED
File without changes
src/utils/file_processors.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File processing utilities for different document types."""
2
+ from pathlib import Path
3
+ from typing import List, Dict, Any, Optional
4
+ import PyPDF2
5
+ from docx import Document
6
+
7
+
8
+ class FileProcessor:
9
+ """Base class for file processing."""
10
+
11
+ @staticmethod
12
+ def get_processor(file_path: Path):
13
+ """Get appropriate processor based on file extension."""
14
+ extension = file_path.suffix.lower()
15
+
16
+ if extension == '.pdf':
17
+ return PDFProcessor()
18
+ elif extension == '.txt':
19
+ return TXTProcessor()
20
+ elif extension == '.docx':
21
+ return DOCXProcessor()
22
+ else:
23
+ raise ValueError(f"Unsupported file type: {extension}")
24
+
25
+ def extract_text(self, file_path: Path) -> str:
26
+ """Extract text from file. Override in subclasses."""
27
+ raise NotImplementedError
28
+
29
+ def chunk_text(self, text: str, chunk_size: int = 500, overlap: int = 50) -> List[str]:
30
+ """
31
+ Split text into overlapping chunks.
32
+
33
+ Args:
34
+ text: Text to chunk
35
+ chunk_size: Target size of each chunk in characters
36
+ overlap: Number of characters to overlap between chunks
37
+
38
+ Returns:
39
+ List of text chunks
40
+ """
41
+ if not text.strip():
42
+ return []
43
+
44
+ chunks = []
45
+ words = text.split()
46
+
47
+ current_chunk = []
48
+ current_length = 0
49
+
50
+ for word in words:
51
+ word_length = len(word) + 1 # +1 for space
52
+
53
+ if current_length + word_length > chunk_size and current_chunk:
54
+ # Save current chunk
55
+ chunk_text = " ".join(current_chunk)
56
+ chunks.append(chunk_text)
57
+
58
+ # Start new chunk with overlap
59
+ # Keep last few words for context
60
+ overlap_words = []
61
+ overlap_length = 0
62
+ for w in reversed(current_chunk):
63
+ if overlap_length + len(w) + 1 <= overlap:
64
+ overlap_words.insert(0, w)
65
+ overlap_length += len(w) + 1
66
+ else:
67
+ break
68
+
69
+ current_chunk = overlap_words
70
+ current_length = overlap_length
71
+
72
+ current_chunk.append(word)
73
+ current_length += word_length
74
+
75
+ # Add remaining words
76
+ if current_chunk:
77
+ chunks.append(" ".join(current_chunk))
78
+
79
+ return chunks
80
+
81
+
82
+ class PDFProcessor(FileProcessor):
83
+ """Processor for PDF files."""
84
+
85
+ def extract_text(self, file_path: Path) -> str:
86
+ """Extract text from PDF file."""
87
+ text = []
88
+
89
+ try:
90
+ with open(file_path, 'rb') as file:
91
+ pdf_reader = PyPDF2.PdfReader(file)
92
+
93
+ for page_num, page in enumerate(pdf_reader.pages):
94
+ page_text = page.extract_text()
95
+ if page_text.strip():
96
+ text.append(page_text)
97
+
98
+ return "\n\n".join(text)
99
+
100
+ except Exception as e:
101
+ raise ValueError(f"Error extracting PDF text: {e}")
102
+
103
+
104
+ class TXTProcessor(FileProcessor):
105
+ """Processor for plain text files."""
106
+
107
+ def extract_text(self, file_path: Path) -> str:
108
+ """Extract text from TXT file."""
109
+ try:
110
+ # Try different encodings
111
+ encodings = ['utf-8', 'utf-16', 'latin-1', 'cp1252']
112
+
113
+ for encoding in encodings:
114
+ try:
115
+ with open(file_path, 'r', encoding=encoding) as file:
116
+ return file.read()
117
+ except UnicodeDecodeError:
118
+ continue
119
+
120
+ raise ValueError("Could not decode text file with any supported encoding")
121
+
122
+ except Exception as e:
123
+ raise ValueError(f"Error reading TXT file: {e}")
124
+
125
+
126
+ class DOCXProcessor(FileProcessor):
127
+ """Processor for DOCX files."""
128
+
129
+ def extract_text(self, file_path: Path) -> str:
130
+ """Extract text from DOCX file."""
131
+ try:
132
+ doc = Document(file_path)
133
+
134
+ # Extract text from paragraphs
135
+ paragraphs = [para.text for para in doc.paragraphs if para.text.strip()]
136
+
137
+ # Extract text from tables
138
+ table_text = []
139
+ for table in doc.tables:
140
+ for row in table.rows:
141
+ row_text = [cell.text.strip() for cell in row.cells if cell.text.strip()]
142
+ if row_text:
143
+ table_text.append(" | ".join(row_text))
144
+
145
+ # Combine all text
146
+ all_text = paragraphs + table_text
147
+ return "\n\n".join(all_text)
148
+
149
+ except Exception as e:
150
+ raise ValueError(f"Error extracting DOCX text: {e}")
151
+
152
+
153
+ def process_document(
154
+ file_path: Path,
155
+ chunk_size: int = 500,
156
+ overlap: int = 50
157
+ ) -> Dict[str, Any]:
158
+ """
159
+ Process a document file and return chunks with metadata.
160
+
161
+ Args:
162
+ file_path: Path to the document file
163
+ chunk_size: Target size of each chunk
164
+ overlap: Overlap between chunks
165
+
166
+ Returns:
167
+ Dictionary with chunks and metadata
168
+ """
169
+ processor = FileProcessor.get_processor(file_path)
170
+
171
+ # Extract text
172
+ text = processor.extract_text(file_path)
173
+
174
+ if not text.strip():
175
+ raise ValueError("No text content found in document")
176
+
177
+ # Chunk text
178
+ chunks = processor.chunk_text(text, chunk_size, overlap)
179
+
180
+ return {
181
+ "filename": file_path.name,
182
+ "file_type": file_path.suffix.lower(),
183
+ "file_size": file_path.stat().st_size,
184
+ "text_length": len(text),
185
+ "chunks": chunks,
186
+ "num_chunks": len(chunks)
187
+ }
tests/__init__.py ADDED
File without changes
tests/test_unit_cases.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test suite for FastAPI Multi-Agent Assistant API.
3
+
4
+ This module tests the core API endpoints including health checks,
5
+ root endpoint, and system initialization.
6
+ """
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+ from src.api.main import app, multi_agent_app
10
+
11
+
12
+ # ============================================================================
13
+ # Test Client Setup
14
+ # ============================================================================
15
+
16
+ @pytest.fixture
17
+ def client():
18
+ """Create test client for FastAPI application."""
19
+ return TestClient(app)
20
+
21
+
22
+ # ============================================================================
23
+ # Root Endpoint Tests
24
+ # ============================================================================
25
+
26
+ def test_root_endpoint(client):
27
+ """Test that root endpoint returns correct API information."""
28
+ response = client.get("/")
29
+
30
+ assert response.status_code == 200
31
+ data = response.json()
32
+
33
+ # Check response structure
34
+ assert "message" in data
35
+ assert "version" in data
36
+ assert "docs" in data
37
+ assert "health" in data
38
+
39
+ # Check specific values
40
+ assert data["message"] == "Multi-Agent Assistant API"
41
+ assert data["version"] == "1.0.0"
42
+ assert data["docs"] == "/docs"
43
+ assert data["health"] == "/health"
44
+
45
+
46
+ def test_root_endpoint_content_type(client):
47
+ """Test that root endpoint returns JSON content."""
48
+ response = client.get("/")
49
+
50
+ assert response.status_code == 200
51
+ assert response.headers["content-type"] == "application/json"
52
+
53
+
54
+ # ============================================================================
55
+ # Health Check Tests
56
+ # ============================================================================
57
+
58
+ def test_health_check_endpoint(client):
59
+ """Test health check endpoint returns proper structure."""
60
+ response = client.get("/health")
61
+
62
+ assert response.status_code == 200
63
+ data = response.json()
64
+
65
+ # Check response structure
66
+ assert "status" in data
67
+ assert "initialized" in data
68
+ assert "agents" in data
69
+
70
+ # Check status values
71
+ assert data["status"] in ["healthy", "initializing"]
72
+ assert isinstance(data["initialized"], bool)
73
+ assert isinstance(data["agents"], dict)
74
+
75
+
76
+ def test_health_check_agents_status(client):
77
+ """Test that health check returns all agent statuses."""
78
+ response = client.get("/health")
79
+
80
+ assert response.status_code == 200
81
+ data = response.json()
82
+
83
+ # Check all agents are present
84
+ expected_agents = ["crypto", "rag", "stock", "search", "finance_tracker"]
85
+ for agent in expected_agents:
86
+ assert agent in data["agents"]
87
+ assert isinstance(data["agents"][agent], bool)
88
+
89
+
90
+ def test_health_check_response_model(client):
91
+ """Test health check response matches Pydantic model."""
92
+ response = client.get("/health")
93
+
94
+ assert response.status_code == 200
95
+ data = response.json()
96
+
97
+ # Validate against expected model structure
98
+ assert set(data.keys()) == {"status", "initialized", "agents"}
99
+
100
+ # Check types
101
+ assert isinstance(data["status"], str)
102
+ assert isinstance(data["initialized"], bool)
103
+ assert isinstance(data["agents"], dict)
104
+
105
+
106
+ # ============================================================================
107
+ # API Documentation Tests
108
+ # ============================================================================
109
+
110
+ def test_openapi_schema_available(client):
111
+ """Test that OpenAPI schema is accessible."""
112
+ response = client.get("/openapi.json")
113
+
114
+ assert response.status_code == 200
115
+ schema = response.json()
116
+
117
+ assert "openapi" in schema
118
+ assert "info" in schema
119
+ assert schema["info"]["title"] == "Multi-Agent Assistant API"
120
+ assert schema["info"]["version"] == "1.0.0"
121
+
122
+
123
+ def test_docs_endpoint_available(client):
124
+ """Test that Swagger docs are accessible."""
125
+ response = client.get("/docs")
126
+
127
+ assert response.status_code == 200
128
+ assert "swagger" in response.text.lower() or "html" in response.headers["content-type"]
129
+
130
+
131
+ def test_redoc_endpoint_available(client):
132
+ """Test that ReDoc documentation is accessible."""
133
+ response = client.get("/redoc")
134
+
135
+ assert response.status_code == 200
136
+
137
+
138
+
139
+ # ============================================================================
140
+ # Error Handling Tests
141
+ # ============================================================================
142
+
143
+ def test_nonexistent_endpoint(client):
144
+ """Test that nonexistent endpoints return 404."""
145
+ response = client.get("/nonexistent")
146
+
147
+ assert response.status_code == 404
148
+
149
+
150
+ def test_invalid_method_on_root(client):
151
+ """Test that invalid HTTP methods return 405."""
152
+ response = client.post("/")
153
+
154
+ assert response.status_code == 405
155
+
156
+
157
+ # ============================================================================
158
+ # Chat Endpoint Tests (Basic)
159
+ # ============================================================================
160
+
161
+ def test_chat_stream_endpoint_requires_post(client):
162
+ """Test that chat stream endpoint only accepts POST."""
163
+ response = client.get("/api/v1/chat/stream")
164
+
165
+ assert response.status_code == 405 # Method Not Allowed
166
+
167
+
168
+ def test_chat_stream_endpoint_exists(client):
169
+ """Test that chat stream endpoint exists."""
170
+ # Send minimal valid request
171
+ response = client.post(
172
+ "/api/v1/chat/stream",
173
+ json={"message": "test"}
174
+ )
175
+
176
+ # Should return 200 or process the request (not 404)
177
+ assert response.status_code != 404
178
+
179
+
180
+ # ============================================================================
181
+ # Upload Endpoint Tests (Basic)
182
+ # ============================================================================
183
+
184
+ def test_upload_endpoint_requires_post(client):
185
+ """Test that upload endpoint only accepts POST."""
186
+ response = client.get("/api/v1/documents/upload")
187
+
188
+ assert response.status_code == 405 # Method Not Allowed
189
+
190
+
191
+ def test_upload_endpoint_exists(client):
192
+ """Test that upload endpoint exists."""
193
+ # Try to access without file (should fail validation, not 404)
194
+ response = client.post("/api/v1/documents/upload")
195
+
196
+ # Should return 422 (validation error) or other, but not 404
197
+ assert response.status_code != 404
198
+
199
+
200
+ # ============================================================================
201
+ # Integration Tests
202
+ # ============================================================================
203
+
204
+ @pytest.mark.asyncio
205
+ async def test_system_initialization():
206
+ """Test that multi-agent system can initialize."""
207
+ # Reset initialization state
208
+ multi_agent_app.initialized = False
209
+
210
+ # Initialize
211
+ result = await multi_agent_app.initialize()
212
+
213
+ # Check initialization
214
+ assert multi_agent_app.initialized is True
215
+ assert multi_agent_app.supervisor is not None
216
+ assert "initialized" in result.lower() or "ready" in result.lower()
217
+
218
+
219
+ def test_agent_status_method():
220
+ """Test get_agent_status returns correct structure."""
221
+ status = multi_agent_app.get_agent_status()
222
+
223
+ assert isinstance(status, dict)
224
+ expected_agents = ["crypto", "rag", "stock", "search", "finance_tracker"]
225
+
226
+ for agent in expected_agents:
227
+ assert agent in status
228
+ assert isinstance(status[agent], bool)
229
+
230
+
231
+ # ============================================================================
232
+ # Performance Tests
233
+ # ============================================================================
234
+
235
+ def test_health_check_response_time(client):
236
+ """Test that health check responds quickly."""
237
+ import time
238
+
239
+ start = time.time()
240
+ response = client.get("/health")
241
+ elapsed = time.time() - start
242
+
243
+ assert response.status_code == 200
244
+ assert elapsed < 1.0 # Should respond in less than 1 second
245
+
246
+
247
+ def test_root_endpoint_response_time(client):
248
+ """Test that root endpoint responds quickly."""
249
+ import time
250
+
251
+ start = time.time()
252
+ response = client.get("/")
253
+ elapsed = time.time() - start
254
+
255
+ assert response.status_code == 200
256
+ assert elapsed < 0.5 # Should respond in less than 0.5 seconds
257
+
258
+
259
+ # ============================================================================
260
+ # Run Tests
261
+ # ============================================================================
262
+
263
+ if __name__ == "__main__":
264
+ pytest.main([__file__, "-v", "--tb=short"])
ui/__init__.py ADDED
File without changes
ui/gradio_app.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main application with Gradio Chat UI for multi-agent LLM system."""
2
+ import asyncio
3
+ import gradio as gr
4
+ from gradio import ChatMessage
5
+ from src.agents.crypto_agent_mcp import CryptoAgentMCP
6
+ from src.agents.rag_agent_mcp import RAGAgentMCP
7
+ from src.agents.stock_agent_mcp import StockAgentMCP
8
+ from src.agents.search_agent_mcp import SearchAgentMCP
9
+ from src.agents.finance_tracker_agent_mcp import FinanceTrackerMCP
10
+ from typing import Dict, List, AsyncGenerator
11
+ from pathlib import Path
12
+ import os
13
+ from src.core.langgraph_supervisor import ReActSupervisor
14
+
15
+ class MultiAgentApp:
16
+ """Main application orchestrating LLM supervisor and agents."""
17
+
18
+ def __init__(self):
19
+ self.crypto_agent = CryptoAgentMCP()
20
+ self.rag_agent = RAGAgentMCP()
21
+ self.stock_agent = StockAgentMCP()
22
+ self.search_agent = SearchAgentMCP()
23
+ self.finance_tracker = FinanceTrackerMCP()
24
+ self.supervisor = None
25
+ self.chat_history: List[Dict[str, str]] = []
26
+ self.initialized = False
27
+
28
+ async def initialize(self):
29
+ """Initialize all agents and supervisor."""
30
+ if not self.initialized:
31
+ print("πŸš€ Initializing Multi-Agent System...")
32
+
33
+ # Initialize agents first
34
+ await self.crypto_agent.initialize()
35
+ await self.rag_agent.initialize()
36
+ await self.stock_agent.initialize()
37
+ await self.search_agent.initialize()
38
+ await self.finance_tracker.initialize()
39
+
40
+ # Initialize supervisor with agent references
41
+ self.supervisor = ReActSupervisor(
42
+ crypto_agent=self.crypto_agent,
43
+ rag_agent=self.rag_agent,
44
+ stock_agent=self.stock_agent,
45
+ search_agent=self.search_agent,
46
+ finance_tracker=self.finance_tracker
47
+ )
48
+
49
+ self.initialized = True
50
+ print("βœ… System initialized with LangGraph supervisor!")
51
+ return "βœ… All agents initialized and ready!"
52
+
53
+ async def process_query_streaming(
54
+ self,
55
+ message: str,
56
+ history: List[Dict[str, str]]
57
+ ) -> AsyncGenerator[ChatMessage, None]:
58
+ """
59
+ Process user query with streaming updates showing intermediate steps.
60
+
61
+ Args:
62
+ message: User's input message
63
+ history: Chat history in Gradio messages format [{"role": "user/assistant", "content": "..."}]
64
+
65
+ Yields:
66
+ ChatMessage objects with metadata for intermediate steps
67
+ """
68
+ if not message.strip():
69
+ yield ChatMessage(role="assistant", content="Please enter a query.")
70
+ return
71
+
72
+ try:
73
+ # Check if system is initialized
74
+ if not self.initialized:
75
+ yield ChatMessage(role="assistant", content="❌ System not initialized. Please restart the application.")
76
+ return
77
+
78
+ # Convert Gradio messages format to internal format
79
+ internal_history = []
80
+ for msg in history:
81
+ role = msg.get("role", "")
82
+ content = msg.get("content", "")
83
+ if role == "user":
84
+ internal_history.append({"user": content})
85
+ elif role == "assistant":
86
+ internal_history.append({"assistant": content})
87
+
88
+ # Track message IDs and action messages for checkmark updates
89
+ message_id = 0
90
+ action_message_ids = {} # Map agent names to their action message IDs
91
+ final_answer_message_id = None # Track final answer message ID
92
+ final_answer_accumulated = "" # Accumulate streaming final answer
93
+
94
+ async for update in self.supervisor.process_streaming(message, history=internal_history):
95
+ if update.get("type") == "thinking":
96
+ # Collect thinking step
97
+ step = update.get("step", 0)
98
+ thought = update.get("thought", "")
99
+ action = update.get("action", "")
100
+ justification = update.get("justification", "")
101
+
102
+ step_content = f"**Thought:** {thought}\n\n"
103
+ step_content += f"**Action:** {action.upper()}\n\n"
104
+ step_content += f"**Justification:** {justification}"
105
+
106
+ message_id += 1
107
+ # Yield intermediate step as ChatMessage with metadata
108
+ thinking_msg = ChatMessage(
109
+ role="assistant",
110
+ content=step_content,
111
+ metadata={
112
+ "title": f"πŸ’­ Step {step}: Reasoning",
113
+ "id": message_id,
114
+ "status": "done"
115
+ }
116
+ )
117
+ yield thinking_msg
118
+
119
+ elif update.get("type") == "action":
120
+ # Show agent call as intermediate step with pending status
121
+ agent = update.get("agent", "unknown")
122
+ action_content = f"Calling **{agent.upper()}** agent to gather information..."
123
+
124
+ message_id += 1
125
+ action_msg_id = message_id
126
+ action_message_ids[agent] = action_msg_id # Store ID for later update
127
+
128
+ action_msg = ChatMessage(
129
+ role="assistant",
130
+ content=action_content,
131
+ metadata={
132
+ "title": f"πŸ”§ Calling {agent.title()} Agent",
133
+ "id": action_msg_id,
134
+ "status": "pending"
135
+ }
136
+ )
137
+ yield action_msg
138
+
139
+ elif update.get("type") == "observation":
140
+ # Extract tool calls from summary
141
+ agent = update.get("agent", "unknown")
142
+ summary = update.get("summary", "")
143
+
144
+ # Split summary into tool calls and results
145
+ tool_calls = []
146
+ results = []
147
+ for line in summary.split('\n'):
148
+ # Match all tool call prefixes from different agents
149
+ if any(prefix in line for prefix in ['πŸ”§ MCP Tool call:', 'πŸ”§ DuckDuckGo call:', 'πŸ”§ MCP Toolbox call:']):
150
+ tool_calls.append(line)
151
+ elif line.strip():
152
+ results.append(line)
153
+
154
+ # Update the action message with tool calls
155
+ if agent in action_message_ids:
156
+ action_msg_id = action_message_ids[agent]
157
+ tool_calls_text = '\n'.join(tool_calls) if tool_calls else ""
158
+ updated_content = f"βœ“ Called **{agent.upper()}** agent\n\n{tool_calls_text}"
159
+
160
+ updated_action_msg = ChatMessage(
161
+ role="assistant",
162
+ content=updated_content,
163
+ metadata={
164
+ "title": f"πŸ”§ Calling {agent.title()} Agent",
165
+ "id": action_msg_id,
166
+ "status": "done"
167
+ }
168
+ )
169
+ yield updated_action_msg
170
+
171
+ # Show only the results in observation (without tool calls)
172
+ if results:
173
+ results_text = '\n'.join(results)
174
+ message_id += 1
175
+
176
+ # Map agent names to emojis
177
+ agent_emojis = {
178
+ "finance_tracker": "πŸ’Ό",
179
+ "crypto": "πŸͺ™",
180
+ "search": "🌐",
181
+ "stock": "πŸ“ˆ",
182
+ "rag": "πŸ“š"
183
+ }
184
+ agent_emoji = agent_emojis.get(agent.lower(), "πŸ“Š")
185
+
186
+ obs_msg = ChatMessage(
187
+ role="assistant",
188
+ content=results_text,
189
+ metadata={
190
+ "title": f"{agent_emoji} {agent.title()} Agent Results",
191
+ "id": message_id,
192
+ "status": "done"
193
+ }
194
+ )
195
+ yield obs_msg
196
+
197
+ elif update.get("type") == "final_start":
198
+ # Start of final answer - create placeholder message
199
+ final_answer_accumulated = ""
200
+ message_id += 1
201
+ final_answer_message_id = message_id
202
+ # Yield initial empty final answer message
203
+ yield ChatMessage(
204
+ role="assistant",
205
+ content="",
206
+ metadata={"id": final_answer_message_id}
207
+ )
208
+
209
+ elif update.get("type") == "final_token":
210
+ # Stream each token of the final answer - update same message
211
+ final_answer_accumulated = update.get("accumulated", "")
212
+ # Update the same final answer message
213
+ yield ChatMessage(
214
+ role="assistant",
215
+ content=final_answer_accumulated,
216
+ metadata={"id": final_answer_message_id}
217
+ )
218
+
219
+ elif update.get("type") == "final_complete":
220
+ # Final answer is complete
221
+ if final_answer_accumulated:
222
+ yield ChatMessage(
223
+ role="assistant",
224
+ content=final_answer_accumulated,
225
+ metadata={"id": final_answer_message_id}
226
+ )
227
+
228
+ elif update.get("type") == "error":
229
+ # Show error
230
+ error = update.get("error", "Unknown error")
231
+ yield ChatMessage(
232
+ role="assistant",
233
+ content=f"**Error:** {error}"
234
+ )
235
+
236
+ # Update chat history
237
+ self.chat_history.append({"user": message})
238
+ if len(self.chat_history) > 20:
239
+ self.chat_history = self.chat_history[-20:]
240
+
241
+ except Exception as e:
242
+ error_msg = f"❌ **Error processing query:** {str(e)}"
243
+ yield ChatMessage(role="assistant", content=error_msg)
244
+
245
+ async def upload_document(
246
+ self,
247
+ file_obj,
248
+ progress=gr.Progress()
249
+ ) -> str:
250
+ """
251
+ Handle document upload to ChromaDB Cloud.
252
+
253
+ Args:
254
+ file_obj: Gradio file object
255
+ progress: Gradio progress tracker
256
+
257
+ Returns:
258
+ Status message
259
+ """
260
+ try:
261
+ if not self.initialized:
262
+ return "❌ System not initialized. Please restart the application."
263
+
264
+ if file_obj is None:
265
+ return "❌ No file selected"
266
+
267
+ # Get file path from Gradio file object
268
+ file_path = file_obj.name if hasattr(file_obj, 'name') else str(file_obj)
269
+
270
+ # Validate file exists
271
+ if not os.path.exists(file_path):
272
+ return f"❌ File not found: {file_path}"
273
+
274
+ # Validate file type
275
+ file_extension = Path(file_path).suffix.lower()
276
+ if file_extension not in ['.pdf', '.txt', '.docx']:
277
+ return f"❌ Unsupported file type: {file_extension}. Supported: PDF, TXT, DOCX"
278
+
279
+ # Progress callback
280
+ def update_progress(percent, message):
281
+ progress(percent, desc=message)
282
+
283
+ # Upload to RAG agent
284
+ result = await self.rag_agent.add_document(
285
+ file_path,
286
+ progress_callback=update_progress
287
+ )
288
+
289
+ if result.get("success"):
290
+ return (
291
+ f"βœ… Successfully uploaded {result['filename']}\n"
292
+ f"πŸ“Š Type: {result['file_type']}\n"
293
+ f"πŸ“¦ Chunks created: {result['chunks_added']}\n"
294
+ f"πŸ“š Total documents in collection: {result['total_documents']}"
295
+ )
296
+ else:
297
+ return f"❌ Upload failed: {result.get('error', 'Unknown error')}"
298
+
299
+ except Exception as e:
300
+ return f"❌ Error uploading document: {str(e)}"
301
+
302
+ async def cleanup(self):
303
+ """Cleanup resources."""
304
+ if self.initialized:
305
+ await self.crypto_agent.cleanup()
306
+ await self.rag_agent.cleanup()
307
+ await self.stock_agent.cleanup()
308
+ await self.search_agent.cleanup()
309
+ await self.finance_tracker.cleanup()
310
+ print("🧹 Cleanup complete")
311
+ self.chat_history.clear()
312
+
313
+ app = MultiAgentApp()
314
+
315
+ try:
316
+ event_loop = asyncio.get_running_loop()
317
+ except RuntimeError:
318
+ event_loop = asyncio.new_event_loop()
319
+ asyncio.set_event_loop(event_loop)
320
+
321
+ def create_ui():
322
+ """Create Gradio interface."""
323
+
324
+ with gr.Blocks(title="PortfolioMind") as interface:
325
+ gr.Markdown("""
326
+ # πŸ€– PortfolioMind MCP - The Multi-Agent FinAssistant
327
+ """)
328
+
329
+ with gr.Row():
330
+ with gr.Column(scale=3):
331
+ # Chat Interface
332
+ chatbot = gr.Chatbot(
333
+ label="Multi-Agent Assistant",
334
+ height=600,
335
+ show_label=True,
336
+ )
337
+
338
+ with gr.Row():
339
+ msg = gr.Textbox(
340
+ label="Your Message",
341
+ placeholder="Ask about crypto, stocks, documents, or search the web...",
342
+ lines=1,
343
+ scale=4
344
+ )
345
+ with gr.Column(scale=1):
346
+ submit_btn = gr.Button("Send", variant="primary")
347
+ clear_btn = gr.Button("Clear Chat")
348
+
349
+ with gr.Row():
350
+ with gr.Column(scale=1):
351
+ gr.Markdown("""
352
+ ### πŸ€– Available Agents:
353
+ - πŸͺ™ **Crypto Agent**: Real-time crypto prices, market data, and trends | Uses Coingecko MCP Server
354
+ - πŸ“Š **Stock Agent**: Stock prices, company info, financial data, and market analysis | Uses Alphavantage MCP Server
355
+ - πŸ’Ό **Finance Tracker**: Manage your personal stock portfolio (add transactions, track performance, get portfolio news)
356
+ - πŸ“š **RAG Agent**: Query your uploaded documents with AI | Uses ChromaDB API
357
+ - πŸ” **Search Agent**: Search the web using DuckDuckGo | Uses DuckDuckGo MCP Server
358
+ """)
359
+
360
+ with gr.Column(scale=1):
361
+ gr.Markdown("""
362
+ ### ❓ Example queries
363
+ - What's the current price of Bitcoin and Ethereum?
364
+ - Add 10 shares of AAPL I bought at $150
365
+ - What's my current portfolio value?
366
+ - Show me news on my portfolio holdings
367
+ - What did Jerome Powell say in his latest speech?
368
+ - Show me Tesla's financial overview
369
+ - Search for latest AI developments
370
+ - What does my document say about [topic]?
371
+ """)
372
+ with gr.Column(scale=1):
373
+ gr.Markdown("""
374
+ ### πŸ€” How it works
375
+ 1. You ask a question
376
+ 2. The ReAct supervisor analyzes your query and plans a strategy
377
+ 3. It calls relevant agents to gather information
378
+ 4. It synthesizes a comprehensive answer from all sources
379
+ """)
380
+ with gr.Column(scale=1):
381
+ # Document Upload
382
+ gr.Markdown("### πŸ“„ Upload Documents")
383
+ file_upload = gr.File(
384
+ label="Upload PDF, TXT, or DOCX",
385
+ file_types=[".pdf", ".txt", ".docx"],
386
+ type="filepath"
387
+ )
388
+ upload_btn = gr.Button("πŸ“€ Upload to RAG", variant="secondary")
389
+ upload_status = gr.Textbox(
390
+ label="Upload Status",
391
+ lines=4,
392
+ interactive=False
393
+ )
394
+
395
+ # System Status
396
+ status_box = gr.Textbox(
397
+ label="Initialization Status",
398
+ value="βœ… All agents initialized and ready!" if app.initialized else "⏳ Initializing...",
399
+ lines=2,
400
+ interactive=False
401
+ )
402
+
403
+ gr.Markdown("""
404
+ ---
405
+ ### πŸ—οΈ System Architecture
406
+
407
+ **ReAct Pattern Supervisor** β†’ Analyzes queries and orchestrates agents through reasoning loops
408
+
409
+ **Agent Workflow:**
410
+ 1. **Think**: Supervisor reasons about what information is needed
411
+ 2. **Act**: Calls appropriate agent(s) to gather information
412
+ 3. **Observe**: Reviews agent responses
413
+ 4. **Repeat**: Continues until sufficient information is gathered
414
+ 5. **Synthesize**: Generates comprehensive final answer
415
+
416
+ Each agent uses Gemini LLM with specialized MCP server tools for their domain.
417
+ """)
418
+
419
+ # Define async wrappers for Gradio
420
+ async def respond_stream(message, chat_history):
421
+ """Streaming response handler with intermediate steps."""
422
+ # Create the full message list once (Gradio pattern: yield same list object)
423
+ messages = chat_history + [{"role": "user", "content": message}]
424
+ final_answer_index = None
425
+ message_id_to_index = {} # Map message IDs to their index
426
+
427
+ async for chat_msg in app.process_query_streaming(message, chat_history):
428
+ # Check if message has an ID (for updating existing messages)
429
+ msg_id = None
430
+ if hasattr(chat_msg, 'metadata') and chat_msg.metadata:
431
+ msg_id = chat_msg.metadata.get('id')
432
+
433
+ # Check if this is a final answer message (has ID but minimal metadata)
434
+ is_final_answer = msg_id and (not chat_msg.metadata.get('title'))
435
+
436
+ if msg_id and msg_id in message_id_to_index:
437
+ # Update existing message (e.g., changing status from pending to done)
438
+ existing_index = message_id_to_index[msg_id]
439
+ messages[existing_index] = chat_msg
440
+ elif is_final_answer:
441
+ if final_answer_index is None:
442
+ # First token of final answer - append once
443
+ final_answer_index = len(messages)
444
+ message_id_to_index[msg_id] = final_answer_index
445
+ messages.append(chat_msg)
446
+ else:
447
+ # Subsequent tokens - replace in place
448
+ messages[final_answer_index] = chat_msg
449
+ else:
450
+ # New intermediate step message
451
+ if msg_id:
452
+ message_id_to_index[msg_id] = len(messages)
453
+ messages.append(chat_msg)
454
+
455
+ # Yield the SAME messages list (following Gradio streaming pattern)
456
+ yield messages
457
+
458
+ def upload_document_sync(file_obj, progress=gr.Progress()):
459
+ """Synchronous wrapper for async document upload."""
460
+ return event_loop.run_until_complete(app.upload_document(file_obj, progress))
461
+
462
+ def clear_chat():
463
+ """Clear chat history."""
464
+ app.chat_history.clear()
465
+ return []
466
+
467
+ # Connect button actions
468
+ msg.submit(
469
+ respond_stream,
470
+ inputs=[msg, chatbot],
471
+ outputs=[chatbot]
472
+ ).then(
473
+ lambda: "",
474
+ outputs=[msg]
475
+ )
476
+
477
+ submit_btn.click(
478
+ respond_stream,
479
+ inputs=[msg, chatbot],
480
+ outputs=[chatbot]
481
+ ).then(
482
+ lambda: "",
483
+ outputs=[msg]
484
+ )
485
+
486
+ clear_btn.click(
487
+ clear_chat,
488
+ outputs=[chatbot]
489
+ )
490
+
491
+ upload_btn.click(
492
+ upload_document_sync,
493
+ inputs=[file_upload],
494
+ outputs=[upload_status]
495
+ )
496
+
497
+ return interface
498
+
499
+
500
+ def main():
501
+ """Main entry point."""
502
+ print("=" * 60)
503
+ print("πŸš€ Starting Multi-Agent Assistant")
504
+ print("=" * 60)
505
+
506
+ # Validate configuration
507
+ try:
508
+ from src.core.config import config
509
+ config.validate()
510
+ print("βœ… Configuration validated")
511
+ except ValueError as e:
512
+ print(f"❌ Configuration Error: {e}")
513
+ return
514
+
515
+ # Initialize all agents at startup
516
+ print("\n⚑ Initializing all agents at startup...")
517
+ event_loop.run_until_complete(app.initialize())
518
+
519
+ interface = create_ui()
520
+
521
+ print("\nπŸ“± Launching Gradio interface...")
522
+ print("🌐 Access the app at: http://localhost:7860")
523
+ print("\nPress Ctrl+C to stop the server")
524
+ print("=" * 60)
525
+
526
+ try:
527
+ interface.launch(
528
+ server_name="0.0.0.0",
529
+ server_port=7860,
530
+ share=True,
531
+ theme=gr.themes.Citrus()
532
+ )
533
+ except KeyboardInterrupt:
534
+ print("\n\nπŸ›‘ Shutting down...")
535
+ # Cleanup
536
+ event_loop.run_until_complete(app.cleanup())
537
+ event_loop.close()
538
+ print("πŸ‘‹ Goodbye!")
539
+
540
+
541
+ if __name__ == "__main__":
542
+ main()
ui/streamlit_app.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """Streamlit UI for Multi-Agent Assistant connecting to FastAPI backend."""
3
+ import streamlit as st
4
+ import requests
5
+ import json
6
+ import os
7
+ from typing import Dict, List, Optional
8
+ import time
9
+ from pathlib import Path
10
+
11
+ # ============================================================================
12
+ # Configuration
13
+ # ============================================================================
14
+
15
+ # FastAPI backend URL
16
+ API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000")
17
+
18
+ # Page configuration
19
+ st.set_page_config(
20
+ page_title="Multi-Agent Assistant",
21
+ page_icon="πŸ€–",
22
+ layout="wide",
23
+ initial_sidebar_state="expanded"
24
+ )
25
+
26
+ # ============================================================================
27
+ # API Client Functions
28
+ # ============================================================================
29
+
30
+ def check_api_health() -> Dict:
31
+ """Check if FastAPI backend is available."""
32
+ try:
33
+ response = requests.get(f"{API_BASE_URL}/health", timeout=5)
34
+ response.raise_for_status()
35
+ return response.json()
36
+ except Exception as e:
37
+ return {"status": "offline", "error": str(e)}
38
+
39
+
40
+ def stream_chat_response(message: str, history: List[Dict]) -> Dict:
41
+ """
42
+ Stream chat response from FastAPI using Server-Sent Events.
43
+
44
+ Yields update dictionaries as they arrive.
45
+ """
46
+ payload = {
47
+ "message": message,
48
+ "history": history
49
+ }
50
+
51
+ try:
52
+ response = requests.post(
53
+ f"{API_BASE_URL}/api/v1/chat/stream",
54
+ json=payload,
55
+ headers={
56
+ "Accept": "text/event-stream",
57
+ "Cache-Control": "no-cache",
58
+ },
59
+ stream=True,
60
+ timeout=120
61
+ )
62
+ response.raise_for_status()
63
+
64
+ # Use iter_lines with smaller chunk size for faster streaming
65
+ for line in response.iter_lines(chunk_size=1, decode_unicode=True):
66
+ if line:
67
+ if line.startswith('data: '):
68
+ data_str = line[6:] # Remove 'data: ' prefix
69
+ try:
70
+ event = json.loads(data_str)
71
+ yield event
72
+ except json.JSONDecodeError:
73
+ continue
74
+ except Exception as e:
75
+ yield {"type": "error", "error": str(e)}
76
+
77
+
78
+ def upload_document(file) -> Dict:
79
+ """Upload document to FastAPI backend."""
80
+ try:
81
+ files = {'file': (file.name, file, file.type)}
82
+ response = requests.post(
83
+ f"{API_BASE_URL}/api/v1/documents/upload",
84
+ files=files,
85
+ timeout=60
86
+ )
87
+ response.raise_for_status()
88
+ return response.json()
89
+ except Exception as e:
90
+ return {"success": False, "message": str(e)}
91
+
92
+
93
+ # ============================================================================
94
+ # Session State Initialization
95
+ # ============================================================================
96
+
97
+ if "messages" not in st.session_state:
98
+ st.session_state.messages = []
99
+
100
+ if "chat_history" not in st.session_state:
101
+ st.session_state.chat_history = []
102
+
103
+ if "show_intermediate_steps" not in st.session_state:
104
+ st.session_state.show_intermediate_steps = True
105
+
106
+ if "processing" not in st.session_state:
107
+ st.session_state.processing = False
108
+
109
+ # ============================================================================
110
+ # Custom CSS
111
+ # ============================================================================
112
+
113
+ st.markdown("""
114
+ <style>
115
+ /* Fixed bottom input container */
116
+ .stChatFloatingInputContainer {
117
+ position: sticky;
118
+ bottom: 0;
119
+ background-color: var(--background-color);
120
+ padding: 1rem 0;
121
+ z-index: 100;
122
+ }
123
+
124
+ .stChatMessage {
125
+ padding: 1rem;
126
+ border-radius: 0.5rem;
127
+ margin-bottom: 0.5rem;
128
+ }
129
+
130
+ .thinking-step {
131
+ background-color: #f0f2f6;
132
+ padding: 1rem;
133
+ border-radius: 0.5rem;
134
+ border-left: 3px solid #4CAF50;
135
+ margin: 0.5rem 0;
136
+ }
137
+
138
+ .action-step {
139
+ background-color: #fff3cd;
140
+ padding: 0.75rem 1rem;
141
+ border-radius: 0.5rem;
142
+ border-left: 3px solid #ffc107;
143
+ margin: 0.5rem 0;
144
+ }
145
+
146
+ .action-step-done {
147
+ background-color: #d4edda;
148
+ padding: 0.75rem 1rem;
149
+ border-radius: 0.5rem;
150
+ border-left: 3px solid #28a745;
151
+ margin: 0.5rem 0;
152
+ }
153
+
154
+ .observation-step {
155
+ background-color: #d1ecf1;
156
+ padding: 1rem;
157
+ border-radius: 0.5rem;
158
+ border-left: 3px solid #17a2b8;
159
+ margin: 0.5rem 0;
160
+ }
161
+
162
+ .agent-badge {
163
+ display: inline-block;
164
+ padding: 0.25rem 0.5rem;
165
+ border-radius: 0.25rem;
166
+ background-color: #6c757d;
167
+ color: white;
168
+ font-size: 0.875rem;
169
+ font-weight: 500;
170
+ }
171
+
172
+ /* Search references styling */
173
+ .search-references {
174
+ margin-top: 1rem;
175
+ padding: 0.75rem;
176
+ background-color: #f8f9fa;
177
+ border-radius: 0.5rem;
178
+ border-left: 3px solid #007bff;
179
+ }
180
+
181
+ .search-references h4 {
182
+ margin-top: 0;
183
+ font-size: 0.9rem;
184
+ color: #495057;
185
+ }
186
+
187
+ .search-references a {
188
+ display: block;
189
+ color: #007bff;
190
+ text-decoration: none;
191
+ margin: 0.25rem 0;
192
+ font-size: 0.85rem;
193
+ }
194
+
195
+ .search-references a:hover {
196
+ text-decoration: underline;
197
+ }
198
+ </style>
199
+ """, unsafe_allow_html=True)
200
+
201
+ # ============================================================================
202
+ # Sidebar
203
+ # ============================================================================
204
+
205
+ with st.sidebar:
206
+ st.title("πŸ€– Multi-Agent Assistant")
207
+ st.markdown("---")
208
+
209
+ # Settings
210
+ st.subheader("βš™οΈ Settings")
211
+ st.session_state.show_intermediate_steps = st.checkbox(
212
+ "Show Reasoning Steps",
213
+ value=st.session_state.show_intermediate_steps,
214
+ help="Display intermediate thinking, actions, and observations"
215
+ )
216
+
217
+ if st.button("πŸ—‘οΈ Clear Chat", use_container_width=True):
218
+ st.session_state.messages = []
219
+ st.session_state.chat_history = []
220
+ st.rerun()
221
+
222
+ st.markdown("---")
223
+
224
+ # Information
225
+ st.subheader("ℹ️ Available Agents")
226
+ st.markdown("""
227
+ - πŸͺ™ **Crypto Agent**: Cryptocurrency prices & data
228
+ - πŸ“Š **Stock Agent**: Stock market information
229
+ - πŸ’Ό **Finance Tracker**: Portfolio management
230
+ - πŸ“š **RAG Agent**: Document Q&A
231
+ - πŸ” **Search Agent**: Web search
232
+ """)
233
+
234
+ st.markdown("---")
235
+ st.caption("Powered by FastAPI + Gemini 2.5 Pro")
236
+
237
+ # ============================================================================
238
+ # Main Chat Interface
239
+ # ============================================================================
240
+
241
+ st.title("πŸ’¬ Multi-Agent Chat Assistant")
242
+ st.markdown("Ask questions about crypto, stocks, documents, or search the web!")
243
+
244
+ # Container for chat messages
245
+ chat_container = st.container()
246
+
247
+ # Display chat history in the container
248
+ with chat_container:
249
+ for message in st.session_state.messages:
250
+ role = message["role"]
251
+ content = message["content"]
252
+
253
+ with st.chat_message(role):
254
+ if role == "assistant" and "metadata" in message:
255
+ metadata = message["metadata"]
256
+
257
+ # Intermediate steps
258
+ if metadata.get("type") == "thinking":
259
+ if st.session_state.show_intermediate_steps:
260
+ with st.expander(f"πŸ’­ Step {metadata.get('step', '?')}: Reasoning", expanded=False):
261
+ st.markdown(f"**Thought:** {metadata.get('thought', 'N/A')}")
262
+ st.markdown(f"**Action:** `{metadata.get('action', 'N/A').upper()}`")
263
+ st.markdown(f"**Justification:** {metadata.get('justification', 'N/A')}")
264
+
265
+ elif metadata.get("type") == "action":
266
+ if st.session_state.show_intermediate_steps:
267
+ agent = metadata.get('agent', 'unknown')
268
+ status = metadata.get('status', 'running')
269
+ if status == 'done':
270
+ st.success(f"βœ… **{agent.title()}** Agent - Done")
271
+ else:
272
+ st.info(f"πŸ”§ Calling **{agent.title()}** Agent...")
273
+
274
+ elif metadata.get("type") == "observation":
275
+ if st.session_state.show_intermediate_steps:
276
+ agent = metadata.get('agent', 'unknown')
277
+ with st.expander(f"πŸ“Š {agent.title()} Agent Results", expanded=False):
278
+ st.write(content)
279
+
280
+ else:
281
+ # Regular assistant message
282
+ st.markdown(content)
283
+
284
+ # Display search references if available
285
+ if metadata.get("search_references"):
286
+ refs = metadata["search_references"]
287
+ st.markdown("---")
288
+ st.markdown("**πŸ”— References:**")
289
+ for ref in refs:
290
+ st.markdown(f"- [{ref['title']}]({ref['url']})")
291
+ else:
292
+ # User or regular message
293
+ st.markdown(content)
294
+
295
+ # Fixed bottom container for input and file upload
296
+ st.markdown("---")
297
+ input_col, upload_col = st.columns([4, 1])
298
+
299
+ with input_col:
300
+ prompt = st.chat_input("Ask me anything...", key="chat_input")
301
+
302
+ with upload_col:
303
+ uploaded_file = st.file_uploader(
304
+ "πŸ“„",
305
+ type=["pdf", "txt", "docx"],
306
+ help="Upload documents to the RAG agent",
307
+ label_visibility="collapsed",
308
+ key="file_uploader"
309
+ )
310
+
311
+ # Handle document upload
312
+ if uploaded_file and not st.session_state.processing:
313
+ with st.spinner("Uploading document..."):
314
+ result = upload_document(uploaded_file)
315
+ if result.get("success"):
316
+ st.success(f"βœ… {result.get('message', 'Upload successful')}")
317
+ else:
318
+ st.error(f"❌ {result.get('message', 'Upload failed')}")
319
+
320
+ # Handle chat input
321
+ if prompt and not st.session_state.processing:
322
+ st.session_state.processing = True
323
+
324
+ # Add user message to chat
325
+ st.session_state.messages.append({"role": "user", "content": prompt})
326
+ st.session_state.chat_history.append({"role": "user", "content": prompt})
327
+
328
+ # Display user message
329
+ with chat_container:
330
+ with st.chat_message("user"):
331
+ st.markdown(prompt)
332
+
333
+ # Get response from API
334
+ with st.chat_message("assistant"):
335
+ # Create placeholders for streaming (thinking steps first, then final answer)
336
+ thinking_placeholder = st.container()
337
+ response_placeholder = st.empty()
338
+
339
+ final_answer = ""
340
+ current_step = 0
341
+ agent_status_placeholders = {}
342
+ search_references = []
343
+ first_event_received = False
344
+ initial_status = None
345
+
346
+ # Show initial status while supervisor starts processing
347
+ if st.session_state.show_intermediate_steps:
348
+ with thinking_placeholder:
349
+ initial_status = st.empty()
350
+ initial_status.info("πŸ€” Supervisor is analyzing your query...")
351
+
352
+ try:
353
+ for event in stream_chat_response(prompt, st.session_state.chat_history[:-1]):
354
+ event_type = event.get("type", "unknown")
355
+
356
+ # Clear initial status on first event
357
+ if not first_event_received and initial_status is not None:
358
+ initial_status.empty()
359
+ first_event_received = True
360
+
361
+ if event_type == "thinking":
362
+ current_step = event.get("step", current_step + 1)
363
+ if st.session_state.show_intermediate_steps:
364
+ with thinking_placeholder:
365
+ with st.expander(f"πŸ’­ Step {current_step}: Reasoning", expanded=False):
366
+ st.markdown(f"**Thought:** {event.get('thought', 'N/A')}")
367
+ st.markdown(f"**Action:** `{event.get('action', 'N/A').upper()}`")
368
+ st.markdown(f"**Justification:** {event.get('justification', 'N/A')}")
369
+
370
+ st.session_state.messages.append({
371
+ "role": "assistant",
372
+ "content": "",
373
+ "metadata": {
374
+ "type": "thinking",
375
+ "step": current_step,
376
+ "thought": event.get('thought', ''),
377
+ "action": event.get('action', ''),
378
+ "justification": event.get('justification', '')
379
+ }
380
+ })
381
+
382
+ elif event_type == "action":
383
+ agent = event.get("agent", "unknown")
384
+ if st.session_state.show_intermediate_steps:
385
+ with thinking_placeholder:
386
+ # Create a placeholder for this agent's status
387
+ agent_status_placeholders[agent] = st.empty()
388
+ agent_status_placeholders[agent].info(f"πŸ”§ Calling **{agent.title()}** Agent...")
389
+
390
+ st.session_state.messages.append({
391
+ "role": "assistant",
392
+ "content": f"Calling {agent} agent...",
393
+ "metadata": {"type": "action", "agent": agent, "status": "running"}
394
+ })
395
+
396
+ elif event_type == "observation":
397
+ agent = event.get("agent", "unknown")
398
+ summary = event.get("summary", "")
399
+
400
+ # Update the agent status to done
401
+ if agent in agent_status_placeholders:
402
+ agent_status_placeholders[agent].success(f"βœ… **{agent.title()}** Agent - Done")
403
+
404
+ # Extract search URLs if this is the search agent
405
+ if agent == "search" and event.get("search_urls"):
406
+ for url_data in event.get("search_urls", []):
407
+ if url_data not in search_references:
408
+ search_references.append(url_data)
409
+
410
+ if st.session_state.show_intermediate_steps:
411
+ with thinking_placeholder:
412
+ with st.expander(f"πŸ“Š {agent.title()} Agent Results", expanded=False):
413
+ st.write(summary)
414
+
415
+ # Update the message to mark as done
416
+ for msg in reversed(st.session_state.messages):
417
+ if (msg.get("role") == "assistant" and
418
+ msg.get("metadata", {}).get("type") == "action" and
419
+ msg.get("metadata", {}).get("agent") == agent):
420
+ msg["metadata"]["status"] = "done"
421
+ break
422
+
423
+ st.session_state.messages.append({
424
+ "role": "assistant",
425
+ "content": summary,
426
+ "metadata": {"type": "observation", "agent": agent}
427
+ })
428
+
429
+ elif event_type == "final_start":
430
+ # Display synthesis status in thinking area, not at top
431
+ if st.session_state.show_intermediate_steps:
432
+ with thinking_placeholder:
433
+ st.info("πŸ”„ Synthesizing final answer...")
434
+
435
+ elif event_type == "final_token":
436
+ # Stream token by token (use write() to handle incomplete markdown)
437
+ final_answer = event.get("accumulated", "")
438
+ response_placeholder.write(final_answer)
439
+
440
+ elif event_type == "final_complete":
441
+ if final_answer:
442
+ # Render final version with proper markdown
443
+ response_placeholder.markdown(final_answer)
444
+
445
+ elif event_type == "error":
446
+ error_msg = event.get("error", "Unknown error")
447
+ response_placeholder.error(f"❌ Error: {error_msg}")
448
+ final_answer = f"Error: {error_msg}"
449
+
450
+ except Exception as e:
451
+ response_placeholder.error(f"❌ Connection Error: {str(e)}")
452
+ final_answer = f"Error: {str(e)}"
453
+
454
+ # Add final answer to chat history
455
+ if final_answer:
456
+ message_data = {
457
+ "role": "assistant",
458
+ "content": final_answer,
459
+ "metadata": {}
460
+ }
461
+
462
+ # Add search references if available
463
+ if search_references:
464
+ message_data["metadata"]["search_references"] = search_references
465
+
466
+ st.session_state.messages.append(message_data)
467
+ st.session_state.chat_history.append({
468
+ "role": "assistant",
469
+ "content": final_answer
470
+ })
471
+
472
+ st.session_state.processing = False
473
+ st.rerun()