agent-mcp 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_mcp/__init__.py +16 -0
- agent_mcp/camel_mcp_adapter.py +521 -0
- agent_mcp/cli.py +47 -0
- agent_mcp/crewai_mcp_adapter.py +281 -0
- agent_mcp/enhanced_mcp_agent.py +601 -0
- agent_mcp/heterogeneous_group_chat.py +798 -0
- agent_mcp/langchain_mcp_adapter.py +458 -0
- agent_mcp/langgraph_mcp_adapter.py +325 -0
- agent_mcp/mcp_agent.py +658 -0
- agent_mcp/mcp_decorator.py +257 -0
- agent_mcp/mcp_langgraph.py +733 -0
- agent_mcp/mcp_transaction.py +97 -0
- agent_mcp/mcp_transport.py +706 -0
- agent_mcp/mcp_transport_enhanced.py +46 -0
- agent_mcp/proxy_agent.py +24 -0
- agent_mcp-0.1.4.dist-info/METADATA +333 -0
- agent_mcp-0.1.4.dist-info/RECORD +49 -0
- {agent_mcp-0.1.2.dist-info → agent_mcp-0.1.4.dist-info}/WHEEL +1 -1
- agent_mcp-0.1.4.dist-info/entry_points.txt +2 -0
- agent_mcp-0.1.4.dist-info/top_level.txt +3 -0
- demos/__init__.py +1 -0
- demos/basic/__init__.py +1 -0
- demos/basic/framework_examples.py +108 -0
- demos/basic/langchain_camel_demo.py +272 -0
- demos/basic/simple_chat.py +355 -0
- demos/basic/simple_integration_example.py +51 -0
- demos/collaboration/collaborative_task_example.py +437 -0
- demos/collaboration/group_chat_example.py +130 -0
- demos/collaboration/simplified_crewai_example.py +39 -0
- demos/langgraph/autonomous_langgraph_network.py +808 -0
- demos/langgraph/langgraph_agent_network.py +415 -0
- demos/langgraph/langgraph_collaborative_task.py +619 -0
- demos/langgraph/langgraph_example.py +227 -0
- demos/langgraph/run_langgraph_examples.py +213 -0
- demos/network/agent_network_example.py +381 -0
- demos/network/email_agent.py +130 -0
- demos/network/email_agent_demo.py +46 -0
- demos/network/heterogeneous_network_example.py +216 -0
- demos/network/multi_framework_example.py +199 -0
- demos/utils/check_imports.py +49 -0
- demos/workflows/autonomous_agent_workflow.py +248 -0
- demos/workflows/mcp_features_demo.py +353 -0
- demos/workflows/run_agent_collaboration_demo.py +63 -0
- demos/workflows/run_agent_collaboration_with_logs.py +396 -0
- demos/workflows/show_agent_interactions.py +107 -0
- demos/workflows/simplified_autonomous_demo.py +74 -0
- functions/main.py +144 -0
- functions/mcp_network_server.py +513 -0
- functions/utils.py +47 -0
- agent_mcp-0.1.2.dist-info/METADATA +0 -475
- agent_mcp-0.1.2.dist-info/RECORD +0 -5
- agent_mcp-0.1.2.dist-info/entry_points.txt +0 -2
- agent_mcp-0.1.2.dist-info/top_level.txt +0 -1
functions/main.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
# Adjust path for Cloud Functions environment if necessary
|
|
9
|
+
if os.environ.get('FUNCTIONS_EMULATOR') != 'true' and '/workspace' not in sys.path:
|
|
10
|
+
sys.path.insert(0, '/workspace')
|
|
11
|
+
|
|
12
|
+
import firebase_functions.options
|
|
13
|
+
from firebase_functions.https_fn import on_request, Request
|
|
14
|
+
# from firebase_functions.https_fn import AsgiRequestAdapter # Not available in 0.4.2
|
|
15
|
+
# from firebase_functions.storage_fn import StorageObjectData, on_object_finalized # Not needed
|
|
16
|
+
|
|
17
|
+
# Import your FastAPI app and Mangum
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from mcp_network_server import router as network_router, find_firebase_key
|
|
20
|
+
from mangum import Mangum
|
|
21
|
+
import base64 # Needed for body encoding
|
|
22
|
+
import urllib.parse # Needed for query string parsing
|
|
23
|
+
|
|
24
|
+
firebase_functions.options.set_global_options(region=firebase_functions.options.SupportedRegion.EUROPE_WEST1)
|
|
25
|
+
|
|
26
|
+
# Initialize Firebase once
|
|
27
|
+
find_firebase_key()
|
|
28
|
+
|
|
29
|
+
# Create a FastAPI app
|
|
30
|
+
app = FastAPI()
|
|
31
|
+
|
|
32
|
+
# --- Root endpoint (Define BEFORE including router) ---
|
|
33
|
+
@app.get("/")
|
|
34
|
+
async def root():
|
|
35
|
+
"""Basic health check endpoint"""
|
|
36
|
+
return {"message": "MCP Network Server is running!"}
|
|
37
|
+
|
|
38
|
+
# Include the router from the network server
|
|
39
|
+
app.include_router(network_router) # This adds routes like /register, /message/*, etc.
|
|
40
|
+
|
|
41
|
+
# Create a Mangum handler
|
|
42
|
+
handler = Mangum(app, lifespan="off") # lifespan="off" can sometimes help in simple cases
|
|
43
|
+
|
|
44
|
+
@on_request()
|
|
45
|
+
def mcp_server(req: Request) -> any:
|
|
46
|
+
"""Handles incoming HTTPS requests by passing them to the FastAPI app via Mangum.
|
|
47
|
+
|
|
48
|
+
Manually constructs an AWS API Gateway V1 event dictionary for Mangum.
|
|
49
|
+
"""
|
|
50
|
+
# Ensure an asyncio event loop exists for the current thread
|
|
51
|
+
try:
|
|
52
|
+
loop = asyncio.get_event_loop_policy().get_event_loop()
|
|
53
|
+
except RuntimeError:
|
|
54
|
+
loop = asyncio.new_event_loop()
|
|
55
|
+
asyncio.set_event_loop(loop)
|
|
56
|
+
print("Created new asyncio event loop for this thread.")
|
|
57
|
+
|
|
58
|
+
# Manually construct an event dictionary resembling AWS API Gateway v1 format
|
|
59
|
+
try:
|
|
60
|
+
query_string = urllib.parse.urlencode(req.args)
|
|
61
|
+
body = req.get_data()
|
|
62
|
+
is_base64_encoded = False
|
|
63
|
+
try:
|
|
64
|
+
# Attempt to decode as UTF-8. If it fails, assume binary and base64 encode.
|
|
65
|
+
body_str = body.decode('utf-8')
|
|
66
|
+
except UnicodeDecodeError:
|
|
67
|
+
body = base64.b64encode(body)
|
|
68
|
+
body_str = body.decode('utf-8')
|
|
69
|
+
is_base64_encoded = True
|
|
70
|
+
|
|
71
|
+
# Process multi-value headers correctly by iterating over headers
|
|
72
|
+
multi_value_headers_dict = {}
|
|
73
|
+
for k, v in req.headers:
|
|
74
|
+
# Normalize header keys to lowercase for consistency
|
|
75
|
+
key_lower = k.lower()
|
|
76
|
+
if key_lower not in multi_value_headers_dict:
|
|
77
|
+
multi_value_headers_dict[key_lower] = []
|
|
78
|
+
multi_value_headers_dict[key_lower].append(v)
|
|
79
|
+
|
|
80
|
+
# Use the lowercase version for single-value headers as well for consistency
|
|
81
|
+
single_value_headers_dict = {k.lower(): v for k, v in req.headers}
|
|
82
|
+
|
|
83
|
+
event = {
|
|
84
|
+
"httpMethod": req.method,
|
|
85
|
+
"path": req.path,
|
|
86
|
+
"queryStringParameters": req.args.to_dict(), # Simple dict for single values
|
|
87
|
+
"headers": single_value_headers_dict, # Simple dict with lowercase keys
|
|
88
|
+
"body": body_str,
|
|
89
|
+
"isBase64Encoded": is_base64_encoded,
|
|
90
|
+
# Minimal required context keys (might need more for complex apps)
|
|
91
|
+
"requestContext": {
|
|
92
|
+
"httpMethod": req.method,
|
|
93
|
+
"path": req.path,
|
|
94
|
+
"requestId": "firebase-function-invocation-" + str(uuid.uuid4()), # More unique ID
|
|
95
|
+
"stage": "prod", # Dummy value
|
|
96
|
+
"apiId": "dummyApiId", # Dummy value
|
|
97
|
+
# Add source IP if available and non-internal
|
|
98
|
+
"identity": {
|
|
99
|
+
"sourceIp": req.remote_addr if req.remote_addr else "127.0.0.1"
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"multiValueQueryStringParameters": {k: vlist for k, vlist in req.args.lists()},
|
|
103
|
+
"multiValueHeaders": multi_value_headers_dict, # Use the correctly processed dict
|
|
104
|
+
"resource": req.path, # Set resource to match the actual path
|
|
105
|
+
"pathParameters": None, # Assuming no path parameters captured by Firebase Function trigger
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Minimal context object (often empty is fine)
|
|
109
|
+
context = {}
|
|
110
|
+
|
|
111
|
+
# Call Mangum handler
|
|
112
|
+
response_data = handler(event, context)
|
|
113
|
+
|
|
114
|
+
# Convert Mangum's response dict back to a Firebase/Flask Response object
|
|
115
|
+
response_headers = response_data.get("headers", {})
|
|
116
|
+
if "multiValueHeaders" in response_data:
|
|
117
|
+
flat_headers = {}
|
|
118
|
+
for key, values in response_data["multiValueHeaders"].items():
|
|
119
|
+
# Use the last value for simplicity, matching typical WSGI behavior
|
|
120
|
+
if values:
|
|
121
|
+
flat_headers[key] = values[-1]
|
|
122
|
+
response_headers = flat_headers # Use the flattened headers
|
|
123
|
+
|
|
124
|
+
response_body = response_data.get("body", "")
|
|
125
|
+
if response_data.get("isBase64Encoded", False):
|
|
126
|
+
# Decode body if Mangum base64 encoded it
|
|
127
|
+
response_body = base64.b64decode(response_body).decode('utf-8')
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"statusCode": response_data.get("statusCode", 500),
|
|
131
|
+
"headers": response_headers,
|
|
132
|
+
"body": response_body,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
# Log the detailed error for debugging
|
|
137
|
+
import traceback
|
|
138
|
+
print(f"Error constructing event or calling handler: {e}")
|
|
139
|
+
traceback.print_exc()
|
|
140
|
+
# Return a generic 500 error
|
|
141
|
+
from flask import Response
|
|
142
|
+
return Response("Internal Server Error", status=500)
|
|
143
|
+
|
|
144
|
+
# Storage trigger placeholder - Removed
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Network Server - Central hub for agent communication.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from fastapi import Request, Depends, HTTPException, APIRouter, Response
|
|
6
|
+
from fastapi.security import HTTPBearer
|
|
7
|
+
from fastapi.responses import StreamingResponse, JSONResponse
|
|
8
|
+
from firebase_admin import initialize_app, get_app, credentials
|
|
9
|
+
from firebase_admin import firestore
|
|
10
|
+
import uvicorn
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
#from google.cloud.firestore_v1.types.document import DocumentSnapshot
|
|
15
|
+
#from google.cloud._helpers import _datetime_to_pb_timestamp
|
|
16
|
+
from typing import Dict, Set, Any, Optional, Union
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
21
|
+
import jwt
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from dotenv import load_dotenv
|
|
24
|
+
from utils import convert_timestamps_to_isoformat
|
|
25
|
+
import logging
|
|
26
|
+
from fastapi import Query
|
|
27
|
+
from pydantic import BaseModel
|
|
28
|
+
from typing import List
|
|
29
|
+
|
|
30
|
+
# Set up logging
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Try to load .env file for local development
|
|
34
|
+
load_dotenv()
|
|
35
|
+
|
|
36
|
+
# Check if we're running in Firebase Cloud Functions
|
|
37
|
+
try:
|
|
38
|
+
import firebase_functions
|
|
39
|
+
IS_FIREBASE = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
IS_FIREBASE = False
|
|
42
|
+
|
|
43
|
+
# Initialize Firebase
|
|
44
|
+
db = None
|
|
45
|
+
|
|
46
|
+
def find_firebase_key():
|
|
47
|
+
"""Find and validate the Firebase key file location."""
|
|
48
|
+
# The exact path that Firebase Functions emulator expects
|
|
49
|
+
key_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'firebase', 'firebase-key.json')
|
|
50
|
+
print(f"Using key path: {key_path}")
|
|
51
|
+
|
|
52
|
+
if not os.path.exists(key_path):
|
|
53
|
+
raise FileNotFoundError(
|
|
54
|
+
f"\nFirebase key not found at {key_path}\n"
|
|
55
|
+
"Please ensure firebase-key.json exists in the functions/firebase/ directory.\n"
|
|
56
|
+
)
|
|
57
|
+
return key_path
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Check if we're running in Firebase Functions
|
|
61
|
+
if IS_FIREBASE:
|
|
62
|
+
print("Running in Firebase Functions environment")
|
|
63
|
+
initialize_app()
|
|
64
|
+
db = firestore.client()
|
|
65
|
+
print("Initialized Firebase Admin using default credentials (sync client)")
|
|
66
|
+
else:
|
|
67
|
+
# Get the key path
|
|
68
|
+
key_path = find_firebase_key()
|
|
69
|
+
|
|
70
|
+
# Initialize Firebase with the key
|
|
71
|
+
print(f"Initializing Firebase Admin with key: {key_path}")
|
|
72
|
+
os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = key_path
|
|
73
|
+
cred = credentials.Certificate(key_path)
|
|
74
|
+
initialize_app(cred)
|
|
75
|
+
db = firestore.client()
|
|
76
|
+
print("Firestore client obtained successfully (sync client)")
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
print(f"CRITICAL Error during Firebase Admin initialization: {e}", file=sys.stderr)
|
|
80
|
+
print("Please ensure you have valid Firebase credentials set up.", file=sys.stderr)
|
|
81
|
+
raise # Re-raise to prevent starting with invalid credentials
|
|
82
|
+
|
|
83
|
+
# Configuration management
|
|
84
|
+
@lru_cache()
|
|
85
|
+
def get_config():
|
|
86
|
+
"""Get configuration using os.getenv for both local and Firebase"""
|
|
87
|
+
if IS_FIREBASE:
|
|
88
|
+
print("Loading config using os.getenv (Firebase environment)")
|
|
89
|
+
# Firebase Functions V2 automatically loads `firebase functions:config:set`
|
|
90
|
+
# values into environment variables, converting keys like `api.openai_key`
|
|
91
|
+
# to `API_OPENAI_KEY`.
|
|
92
|
+
openai_api_key = os.getenv('API_OPENAI_KEY')
|
|
93
|
+
gemini_api_key = os.getenv('API_GEMINI_KEY')
|
|
94
|
+
jwt_secret = os.getenv('JWT_SECRET')
|
|
95
|
+
server_port = int(os.getenv('SERVER_PORT', '8000')) # Default if not set in config
|
|
96
|
+
server_host = os.getenv('SERVER_HOST', '0.0.0.0') # Default if not set
|
|
97
|
+
jwt_expiration_minutes = int(os.getenv('JWT_EXPIRATION_MINUTES', '60')) # Default if not set
|
|
98
|
+
else:
|
|
99
|
+
# Local development uses .env file loaded by dotenv
|
|
100
|
+
print("Loading config from local .env file using os.getenv")
|
|
101
|
+
openai_api_key = os.getenv('OPENAI_API_KEY') # Standard name for .env
|
|
102
|
+
gemini_api_key = os.getenv('GOOGLE_GEMINI_API_KEY') # Standard name for .env
|
|
103
|
+
jwt_secret = os.getenv('JWT_SECRET')
|
|
104
|
+
server_port = int(os.getenv('PORT', '8000')) # Local often uses PORT
|
|
105
|
+
server_host = os.getenv('HOST', '0.0.0.0')
|
|
106
|
+
jwt_expiration_minutes = int(os.getenv('JWT_EXPIRATION_MINUTES', '60'))
|
|
107
|
+
|
|
108
|
+
# --- Logging ---
|
|
109
|
+
# Use consistent logging regardless of environment
|
|
110
|
+
env_type = "Firebase" if IS_FIREBASE else "Local"
|
|
111
|
+
print(f"{env_type} Env Config: API_OPENAI_KEY retrieved: {'present' if openai_api_key else 'MISSING'}", file=sys.stderr)
|
|
112
|
+
print(f"{env_type} Env Config: API_GEMINI_KEY retrieved: {'present' if gemini_api_key else 'MISSING'}", file=sys.stderr)
|
|
113
|
+
print(f"{env_type} Env Config: JWT_SECRET retrieved: {'present' if jwt_secret else 'MISSING'}", file=sys.stderr)
|
|
114
|
+
print(f"{env_type} Env Config: SERVER_PORT retrieved: {server_port}", file=sys.stderr)
|
|
115
|
+
print(f"{env_type} Env Config: SERVER_HOST retrieved: {server_host}", file=sys.stderr)
|
|
116
|
+
print(f"{env_type} Env Config: JWT_EXPIRATION_MINUTES retrieved: {jwt_expiration_minutes}", file=sys.stderr)
|
|
117
|
+
|
|
118
|
+
# --- Check JWT Secret --- (Crucial check)
|
|
119
|
+
if not jwt_secret:
|
|
120
|
+
print(f"CRITICAL ERROR in get_config ({env_type}): JWT_SECRET is missing! Check environment variables / Firebase config.", file=sys.stderr)
|
|
121
|
+
# In a real scenario, you might raise an error or prevent startup
|
|
122
|
+
# raise ValueError("JWT_SECRET is required but not found")
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
'openai_api_key': openai_api_key,
|
|
126
|
+
'gemini_api_key': gemini_api_key,
|
|
127
|
+
'server_port': server_port,
|
|
128
|
+
'server_host': server_host,
|
|
129
|
+
'jwt_secret': jwt_secret,
|
|
130
|
+
'jwt_expiration_minutes': jwt_expiration_minutes
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# JWT Configuration
|
|
134
|
+
JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
|
135
|
+
|
|
136
|
+
router = APIRouter()
|
|
137
|
+
security = HTTPBearer()
|
|
138
|
+
|
|
139
|
+
class MessageQueue:
|
|
140
|
+
def __init__(self):
|
|
141
|
+
self.messages_ref = db.collection('messages')
|
|
142
|
+
|
|
143
|
+
def push_message(self, target_id: str, message: dict):
|
|
144
|
+
"""Push a message to the target's queue"""
|
|
145
|
+
try:
|
|
146
|
+
# Always set timestamp to ensure consistency
|
|
147
|
+
message['timestamp'] = datetime.utcnow().replace(tzinfo=timezone.utc)
|
|
148
|
+
|
|
149
|
+
# Add acknowledged flag
|
|
150
|
+
message['acknowledged'] = False
|
|
151
|
+
|
|
152
|
+
# Create a new document reference with auto-generated ID
|
|
153
|
+
queue_ref = self.messages_ref.document(target_id).collection('queue')
|
|
154
|
+
doc_ref = queue_ref.document()
|
|
155
|
+
message_id = doc_ref.id
|
|
156
|
+
|
|
157
|
+
# Add the ID to the message before saving
|
|
158
|
+
message['id'] = message_id
|
|
159
|
+
|
|
160
|
+
# Save the message
|
|
161
|
+
doc_ref.set(message)
|
|
162
|
+
print(f"Pushed message {message_id} to {target_id}")
|
|
163
|
+
return message_id
|
|
164
|
+
except Exception as e:
|
|
165
|
+
print(f"Error pushing message to {target_id}: {e}")
|
|
166
|
+
raise HTTPException(status_code=500, detail=f"Error pushing message: {e}")
|
|
167
|
+
|
|
168
|
+
def get_messages(self, agent_id: str, last_message_id: str = None) -> list:
|
|
169
|
+
"""Get all unacknowledged messages for an agent after the last_message_id"""
|
|
170
|
+
messages = []
|
|
171
|
+
try:
|
|
172
|
+
print(f"\n====== FETCHING MESSAGES FOR {agent_id} ======")
|
|
173
|
+
print(f"Last message ID: {last_message_id}")
|
|
174
|
+
|
|
175
|
+
# Build the query
|
|
176
|
+
query = self.messages_ref.document(agent_id).collection('queue')
|
|
177
|
+
|
|
178
|
+
# Only get unacknowledged messages
|
|
179
|
+
query = query.where('acknowledged', '==', False)
|
|
180
|
+
|
|
181
|
+
# Order by timestamp descending to get newest first
|
|
182
|
+
query = query.order_by('timestamp', direction=firestore.Query.DESCENDING)
|
|
183
|
+
|
|
184
|
+
# Get current time in UTC
|
|
185
|
+
current_time = datetime.utcnow().replace(tzinfo=timezone.utc)
|
|
186
|
+
cutoff_time = current_time - timedelta(minutes=1) # Only get messages from last 1 minute
|
|
187
|
+
|
|
188
|
+
# Add timestamp filter to only get recent messages
|
|
189
|
+
query = query.where('timestamp', '>', cutoff_time)
|
|
190
|
+
|
|
191
|
+
# If we have a last_message_id, get the timestamp of that message
|
|
192
|
+
if last_message_id:
|
|
193
|
+
try:
|
|
194
|
+
# Correctly get the document from the collection reference
|
|
195
|
+
last_msg_ref = self.messages_ref.document(agent_id).collection('queue').document(last_message_id)
|
|
196
|
+
last_msg = last_msg_ref.get()
|
|
197
|
+
if last_msg.exists:
|
|
198
|
+
last_timestamp = last_msg.to_dict().get('timestamp')
|
|
199
|
+
if last_timestamp:
|
|
200
|
+
# Only get messages after the last one
|
|
201
|
+
query = query.where('timestamp', '>', last_timestamp)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
print(f"Error getting last message {last_message_id}: {e}")
|
|
204
|
+
|
|
205
|
+
# Limit to 10 messages at a time
|
|
206
|
+
query = query.limit(10)
|
|
207
|
+
|
|
208
|
+
# Process documents
|
|
209
|
+
for doc in query.stream():
|
|
210
|
+
msg_data = doc.to_dict()
|
|
211
|
+
msg_data['id'] = doc.id
|
|
212
|
+
msg_data = convert_timestamps_to_isoformat(msg_data)
|
|
213
|
+
messages.append(msg_data)
|
|
214
|
+
|
|
215
|
+
# Log message details
|
|
216
|
+
print(f"\n----- Message {msg_data['id']} -----")
|
|
217
|
+
print(f"Type: {msg_data.get('type', 'unknown')}")
|
|
218
|
+
print(f"Task ID: {msg_data.get('task_id', 'unknown')}")
|
|
219
|
+
print(f"Acknowledged: {msg_data.get('acknowledged', False)}")
|
|
220
|
+
print(f"Timestamp: {msg_data.get('timestamp', 'unknown')}")
|
|
221
|
+
if 'content' in msg_data:
|
|
222
|
+
print(f"Content: {json.dumps(msg_data['content'], indent=2)}")
|
|
223
|
+
if 'description' in msg_data:
|
|
224
|
+
print(f"Description: {msg_data['description']}")
|
|
225
|
+
if 'reply_to' in msg_data:
|
|
226
|
+
print(f"Reply To: {msg_data['reply_to']}")
|
|
227
|
+
print("-" * 40)
|
|
228
|
+
|
|
229
|
+
print(f"Found {len(messages)} messages")
|
|
230
|
+
print("====== END MESSAGES ======\n")
|
|
231
|
+
return messages
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
print(f"Error getting messages for {agent_id}: {e}")
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
def delete_message(self, target_id: str, message_id: str):
|
|
238
|
+
"""Delete a message from the target's queue"""
|
|
239
|
+
try:
|
|
240
|
+
# Use synchronous delete
|
|
241
|
+
self.messages_ref.document(target_id).collection('queue').document(message_id).delete()
|
|
242
|
+
print(f"Deleted message {message_id} for {target_id}")
|
|
243
|
+
except Exception as e:
|
|
244
|
+
print(f"Error deleting message {message_id} for {target_id}: {e}")
|
|
245
|
+
raise HTTPException(status_code=500, detail=f"Error deleting message: {e}")
|
|
246
|
+
|
|
247
|
+
def acknowledge_message(self, target_id: str, message_id: str):
|
|
248
|
+
"""Mark a message as acknowledged"""
|
|
249
|
+
try:
|
|
250
|
+
# Use synchronous update
|
|
251
|
+
doc_ref = self.messages_ref.document(target_id).collection('queue').document(message_id)
|
|
252
|
+
doc_ref.update({'acknowledged': True, 'acknowledged_at': datetime.utcnow().replace(tzinfo=timezone.utc)}) # doc_ref.update({'acknowledged': True, 'acknowledged_at': firestore.SERVER_TIMESTAMP})
|
|
253
|
+
print(f"Acknowledged message {message_id} for {target_id}")
|
|
254
|
+
except Exception as e:
|
|
255
|
+
print(f"Error acknowledging message {message_id} for {target_id}: {e}")
|
|
256
|
+
raise HTTPException(status_code=500, detail=f"Error acknowledging message: {e}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class AgentRegistry:
|
|
260
|
+
def __init__(self, db):
|
|
261
|
+
self.agents_ref = db.collection('agents')
|
|
262
|
+
|
|
263
|
+
def register(self, agent_id: str, info: dict):
|
|
264
|
+
"""Register an agent"""
|
|
265
|
+
try:
|
|
266
|
+
agent_data = {
|
|
267
|
+
'info': info,
|
|
268
|
+
'registered_at': datetime.utcnow().isoformat(),
|
|
269
|
+
'last_heartbeat': datetime.utcnow().isoformat(),
|
|
270
|
+
'last_seen': datetime.utcnow().isoformat() # Keep this for backward compatibility
|
|
271
|
+
}
|
|
272
|
+
# Run Firestore operations in a thread to avoid blocking
|
|
273
|
+
self.agents_ref.document(agent_id).set(agent_data)
|
|
274
|
+
print(f"Agent {agent_id} registered successfully")
|
|
275
|
+
return agent_id
|
|
276
|
+
except Exception as e:
|
|
277
|
+
print(f"Error registering agent {agent_id}: {e}")
|
|
278
|
+
raise e
|
|
279
|
+
|
|
280
|
+
def get_agent_info(self, agent_id: str):
|
|
281
|
+
"""Get agent info"""
|
|
282
|
+
try:
|
|
283
|
+
doc = self.agents_ref.document(agent_id).get()
|
|
284
|
+
if doc.exists:
|
|
285
|
+
return doc.to_dict().get('info')
|
|
286
|
+
return None
|
|
287
|
+
except Exception as e:
|
|
288
|
+
print(f"Error getting agent info for {agent_id}: {e}")
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def heartbeat(self, agent_id: str):
|
|
292
|
+
"""Update agent's heartbeat"""
|
|
293
|
+
try:
|
|
294
|
+
self.agents_ref.document(agent_id).update({
|
|
295
|
+
'last_heartbeat': datetime.utcnow().isoformat(),
|
|
296
|
+
'last_seen': datetime.utcnow().isoformat()
|
|
297
|
+
})
|
|
298
|
+
except Exception as e:
|
|
299
|
+
print(f"Error updating heartbeat for {agent_id}: {e}")
|
|
300
|
+
|
|
301
|
+
def list_agents(self):
|
|
302
|
+
"""List all agents"""
|
|
303
|
+
try:
|
|
304
|
+
docs = self.agents_ref.stream()
|
|
305
|
+
return [{"id": doc.id, **doc.to_dict()} for doc in docs]
|
|
306
|
+
except Exception as e:
|
|
307
|
+
print(f"Error listing agents: {e}")
|
|
308
|
+
return []
|
|
309
|
+
|
|
310
|
+
# Initialize services
|
|
311
|
+
message_queue = MessageQueue()
|
|
312
|
+
agent_registry = AgentRegistry(db)
|
|
313
|
+
|
|
314
|
+
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
|
315
|
+
try:
|
|
316
|
+
print(f"Verifying token: {credentials.credentials[:10]}...") # Only print first 10 chars for security
|
|
317
|
+
# Get secret from config
|
|
318
|
+
config = get_config()
|
|
319
|
+
jwt_secret = config.get('jwt_secret')
|
|
320
|
+
if not jwt_secret:
|
|
321
|
+
print("ERROR in verify_token: JWT Secret not found in config")
|
|
322
|
+
raise HTTPException(status_code=500, detail="Server configuration error: JWT secret missing.")
|
|
323
|
+
|
|
324
|
+
token_data = jwt.decode(credentials.credentials, jwt_secret, algorithms=[JWT_ALGORITHM])
|
|
325
|
+
print(f"Token decoded successfully: {token_data}")
|
|
326
|
+
agent_id = token_data.get("agent_id")
|
|
327
|
+
if not agent_id:
|
|
328
|
+
print("Token missing agent_id")
|
|
329
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
330
|
+
return token_data
|
|
331
|
+
except Exception as e:
|
|
332
|
+
print(f"Token verification failed: {str(e)}")
|
|
333
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
334
|
+
|
|
335
|
+
@router.post("/register")
|
|
336
|
+
async def register_agent(request: Request):
|
|
337
|
+
"""Register an agent"""
|
|
338
|
+
try:
|
|
339
|
+
# Await request.json()
|
|
340
|
+
data = await request.json()
|
|
341
|
+
agent_id = data.get('agent_id')
|
|
342
|
+
info = data.get('info', {})
|
|
343
|
+
|
|
344
|
+
if not agent_id:
|
|
345
|
+
raise HTTPException(status_code=400, detail="Missing agent_id")
|
|
346
|
+
|
|
347
|
+
# Register agent
|
|
348
|
+
agent_registry.register(agent_id, info)
|
|
349
|
+
|
|
350
|
+
# Generate token
|
|
351
|
+
expiration = datetime.utcnow() + timedelta(minutes=int(os.getenv('JWT_EXPIRATION_MINUTES', '60')))
|
|
352
|
+
token_data = {
|
|
353
|
+
'agent_id': agent_id,
|
|
354
|
+
'type': None,
|
|
355
|
+
'exp': expiration
|
|
356
|
+
}
|
|
357
|
+
print(f"Generating token with data: {token_data}")
|
|
358
|
+
token = jwt.encode(
|
|
359
|
+
token_data,
|
|
360
|
+
get_config().get('jwt_secret'),
|
|
361
|
+
algorithm=JWT_ALGORITHM
|
|
362
|
+
)
|
|
363
|
+
print(f"Generated token: {token[:10]}...")
|
|
364
|
+
|
|
365
|
+
response = {
|
|
366
|
+
'status': 'registered',
|
|
367
|
+
'agent_id': agent_id,
|
|
368
|
+
'token': token
|
|
369
|
+
}
|
|
370
|
+
return response
|
|
371
|
+
except Exception as e:
|
|
372
|
+
print(f"Registration error: {str(e)}")
|
|
373
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
374
|
+
|
|
375
|
+
@router.post("/message/{target_id}")
|
|
376
|
+
async def handle_message(
|
|
377
|
+
target_id: str,
|
|
378
|
+
request: Request,
|
|
379
|
+
token_data: dict = Depends(verify_token)
|
|
380
|
+
):
|
|
381
|
+
"""Handle message delivery to target agent"""
|
|
382
|
+
# Verify target exists
|
|
383
|
+
target = agent_registry.get_agent_info(target_id)
|
|
384
|
+
if not target:
|
|
385
|
+
raise HTTPException(status_code=404, detail="Agent not found")
|
|
386
|
+
|
|
387
|
+
# Await request.json()
|
|
388
|
+
message = await request.json()
|
|
389
|
+
message["from"] = token_data["agent_id"]
|
|
390
|
+
|
|
391
|
+
# Store message
|
|
392
|
+
message_id = message_queue.push_message(target_id, message)
|
|
393
|
+
|
|
394
|
+
return {"status": "delivered", "message_id": message_id}
|
|
395
|
+
|
|
396
|
+
@router.get("/messages/{agent_id}")
|
|
397
|
+
async def get_messages_endpoint(agent_id: str, last_message_id: Optional[str] = Query(None), token_data: dict = Depends(verify_token)):
|
|
398
|
+
"""Endpoint for clients to poll for new messages.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
agent_id: The ID of the agent requesting messages.
|
|
402
|
+
last_message_id: The ID of the last message the client received (optional).
|
|
403
|
+
token_data: Decoded JWT token data.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
A list of new messages.
|
|
407
|
+
|
|
408
|
+
Raises:
|
|
409
|
+
HTTPException: 403 if token doesn't match agent_id.
|
|
410
|
+
HTTPException: 500 if there's an error fetching messages.
|
|
411
|
+
"""
|
|
412
|
+
# Verify the token belongs to the agent asking for messages
|
|
413
|
+
if token_data["agent_id"] != agent_id:
|
|
414
|
+
logger.warning(f"Auth Error: Token agent ({token_data['agent_id']}) mismatch with requested agent_id ({agent_id}) for polling")
|
|
415
|
+
raise HTTPException(status_code=403, detail="Token does not match requested agent ID")
|
|
416
|
+
|
|
417
|
+
logger.info(f"Polling request for {agent_id}. Last message ID: {last_message_id}")
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
message_queue = MessageQueue() # Instantiate locally for the request
|
|
421
|
+
# Run the synchronous Firestore call in a thread pool executor
|
|
422
|
+
loop = asyncio.get_event_loop()
|
|
423
|
+
messages = await loop.run_in_executor(
|
|
424
|
+
None, # Use default executor
|
|
425
|
+
message_queue.get_messages,
|
|
426
|
+
agent_id,
|
|
427
|
+
last_message_id
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
logger.info(f"Found {len(messages)} messages for {agent_id} after {last_message_id}")
|
|
431
|
+
|
|
432
|
+
# Return empty list if no messages
|
|
433
|
+
if not messages:
|
|
434
|
+
logger.debug(f"No messages found for agent {agent_id}")
|
|
435
|
+
return []
|
|
436
|
+
|
|
437
|
+
# Unwrap Firestore documents into a consistent format
|
|
438
|
+
formatted_messages = []
|
|
439
|
+
for msg in messages:
|
|
440
|
+
# Convert Firestore document to dict if needed
|
|
441
|
+
msg_dict = msg.to_dict() if hasattr(msg, 'to_dict') else msg
|
|
442
|
+
logger.info(f"Processing message for {agent_id}: {json.dumps(msg_dict, indent=2)}")
|
|
443
|
+
|
|
444
|
+
# Skip invalid messages
|
|
445
|
+
if not isinstance(msg_dict, dict):
|
|
446
|
+
logger.warning(f"Skipping invalid message format for {agent_id}: {msg_dict}")
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
# Get message ID
|
|
450
|
+
msg_id = msg_dict.get('id')
|
|
451
|
+
if not msg_id and hasattr(msg, 'id'):
|
|
452
|
+
msg_id = msg.id
|
|
453
|
+
logger.debug(f"Using Firestore document ID for message: {msg_id}")
|
|
454
|
+
|
|
455
|
+
# Skip messages without ID
|
|
456
|
+
if not msg_id:
|
|
457
|
+
logger.warning(f"Skipping message without ID for {agent_id}: {msg_dict}")
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
# Format message with required fields
|
|
461
|
+
formatted_msg = {
|
|
462
|
+
'id': msg_id,
|
|
463
|
+
'type': msg_dict.get('type', 'message'), # Default to 'message' type
|
|
464
|
+
'content': msg_dict.get('content', {'text': 'No content provided'}), # Always provide content
|
|
465
|
+
'timestamp': msg_dict.get('timestamp', datetime.utcnow().isoformat()), # Default to current time
|
|
466
|
+
'from': msg_dict.get('from', 'unknown'), # Default to unknown sender
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
# Include optional fields if present
|
|
470
|
+
for field in ['task_id', 'description', 'reply_to', 'result']:
|
|
471
|
+
if field in msg_dict:
|
|
472
|
+
formatted_msg[field] = msg_dict[field]
|
|
473
|
+
|
|
474
|
+
logger.info(f"Formatted message for {agent_id}: {json.dumps(formatted_msg, indent=2)}")
|
|
475
|
+
formatted_messages.append(formatted_msg)
|
|
476
|
+
|
|
477
|
+
return formatted_messages
|
|
478
|
+
except Exception as e:
|
|
479
|
+
logger.error(f"Error fetching messages for {agent_id}: {e}", exc_info=True)
|
|
480
|
+
raise HTTPException(status_code=500, detail=f"Failed to fetch messages: {str(e)}")
|
|
481
|
+
|
|
482
|
+
@router.post("/message/{agent_id}/acknowledge/{message_id}")
|
|
483
|
+
async def acknowledge_message(
|
|
484
|
+
agent_id: str,
|
|
485
|
+
message_id: str,
|
|
486
|
+
request: Request,
|
|
487
|
+
token_data: dict = Depends(verify_token)
|
|
488
|
+
):
|
|
489
|
+
"""Acknowledge message receipt and processing"""
|
|
490
|
+
print(f"[{agent_id}] Received acknowledgment request for message {message_id}")
|
|
491
|
+
|
|
492
|
+
if token_data["agent_id"] != agent_id:
|
|
493
|
+
print(f"[{agent_id}] Authorization failed for message {message_id}. Token agent_id: {token_data['agent_id']}")
|
|
494
|
+
raise HTTPException(status_code=403, detail="Not authorized")
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
# Mark message as acknowledged
|
|
498
|
+
print(f"[{agent_id}] Attempting to acknowledge message {message_id}")
|
|
499
|
+
message_queue.acknowledge_message(agent_id, message_id)
|
|
500
|
+
print(f"[{agent_id}] Successfully acknowledged message {message_id}")
|
|
501
|
+
return {"status": "acknowledged", "message_id": message_id}
|
|
502
|
+
except Exception as e:
|
|
503
|
+
print(f"[{agent_id}] Error acknowledging message {message_id}: {e}")
|
|
504
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
505
|
+
|
|
506
|
+
@router.get("/agents")
|
|
507
|
+
async def list_agents(token_data: dict = Depends(verify_token)):
|
|
508
|
+
"""List all connected agents"""
|
|
509
|
+
agents = agent_registry.list_agents()
|
|
510
|
+
return {"agents": agents}
|
|
511
|
+
|
|
512
|
+
if __name__ == "__main__":
|
|
513
|
+
uvicorn.run(router, host=get_config()['server_host'], port=get_config()['server_port'])
|