agent-mcp 0.1.2__py3-none-any.whl → 0.1.3__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.
@@ -0,0 +1,424 @@
1
+ """
2
+ HeterogeneousGroupChat - A group chat implementation for heterogeneous agents.
3
+
4
+ This module provides a high-level abstraction for creating group chats with agents
5
+ from different frameworks (Autogen, Langchain, etc.) that can collaborate on tasks.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ from typing import List, Dict, Any, Optional, Union, Sequence
11
+ from .mcp_transport import HTTPTransport
12
+ from .enhanced_mcp_agent import EnhancedMCPAgent
13
+ from .mcp_agent import MCPAgent
14
+
15
+ class HeterogeneousGroupChat:
16
+ """
17
+ A group chat for heterogeneous agents that abstracts away the complexity
18
+ of setting up connections and coordinating tasks between different frameworks.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ name: str,
24
+ server_url: str = "https://mcp-server-ixlfhxquwq-ew.a.run.app",
25
+ coordinator_config: Optional[Dict[str, Any]] = None
26
+ ):
27
+ """
28
+ Initialize a heterogeneous group chat.
29
+
30
+ Args:
31
+ name: Name of the group chat
32
+ server_url: URL of the deployed MCP server
33
+ coordinator_config: Optional configuration for the coordinator agent
34
+ """
35
+ self.name = name
36
+ self.server_url = server_url
37
+ self.agents: List[MCPAgent] = []
38
+ self.coordinator: Optional[EnhancedMCPAgent] = None
39
+ self.coordinator_config = coordinator_config or {}
40
+ self.coordinator_url = server_url
41
+ self.agent_tokens: Dict[str, str] = {} # Store agent tokens
42
+ self._register_event = asyncio.Event()
43
+ self._agent_tasks = [] # Initialize list to store agent tasks
44
+ # Initialize directly on the group chat instance first
45
+ self.task_results: Dict[str, Any] = {}
46
+ self.task_dependencies: Dict[str, Dict] = {}
47
+
48
+ def _get_agent_url(self, agent_name: str) -> str:
49
+ """Get the URL for an agent on the deployed server"""
50
+ return f"{self.server_url}/agents/{agent_name}"
51
+
52
+ def create_coordinator(self, api_key: str) -> EnhancedMCPAgent:
53
+ """Create the coordinator agent for the group chat"""
54
+ # Avoid creating coordinator if it already exists
55
+ if self.coordinator:
56
+ return self.coordinator
57
+
58
+ # Define coordinator name (use config if provided, else default)
59
+ coordinator_name = self.coordinator_config.get("name", f"{self.name}Coordinator")
60
+
61
+ # Create transport for coordinator, passing its name
62
+ coordinator_transport = HTTPTransport.from_url(
63
+ self.server_url,
64
+ agent_name=coordinator_name
65
+ )
66
+
67
+ # --- Default Coordinator Configuration ---
68
+ default_config = {
69
+ "name": coordinator_name,
70
+ "transport": coordinator_transport,
71
+ "system_message": "You are a helpful AI assistant coordinating tasks between other specialized agents. You receive task results and ensure the overall goal is achieved.",
72
+ "llm_config": {
73
+ # Default model, can be overridden by coordinator_config
74
+ "config_list": [{
75
+ "model": "gpt-3.5-turbo",
76
+ "api_key": api_key
77
+ }],
78
+ "cache_seed": 42 # Or None for no caching
79
+ },
80
+ }
81
+
82
+ # --- Merge Default and User Config ---
83
+ # User config takes precedence
84
+ final_config = default_config.copy() # Start with defaults
85
+ final_config.update(self.coordinator_config) # Update with user overrides
86
+
87
+ # Ensure llm_config is properly structured if overridden
88
+ if "llm_config" in self.coordinator_config and "config_list" not in final_config["llm_config"]:
89
+ print("Warning: coordinator_config provided llm_config without config_list. Re-structuring.")
90
+ # Assume the user provided a simple dict like {"api_key": ..., "model": ...}
91
+ # We need to wrap it in config_list for AutoGen
92
+ user_llm_config = final_config["llm_config"]
93
+ final_config["llm_config"] = {
94
+ "config_list": [user_llm_config],
95
+ "cache_seed": user_llm_config.get("cache_seed", 42)
96
+ }
97
+ elif "llm_config" in final_config and "api_key" not in final_config["llm_config"].get("config_list", [{}])[0]:
98
+ # If llm_config exists but api_key is missing in the primary config
99
+ print("Warning: api_key missing in llm_config config_list. Injecting from create_coordinator argument.")
100
+ if "config_list" not in final_config["llm_config"]:
101
+ final_config["llm_config"]["config_list"] = [{}]
102
+ final_config["llm_config"]["config_list"][0]["api_key"] = api_key
103
+
104
+
105
+ # --- Create Coordinator Agent ---
106
+ print(f"Creating coordinator with config: {final_config}") # Debug: Log final config
107
+ self.coordinator = EnhancedMCPAgent(**final_config)
108
+
109
+ # --- Set Message Handler ---
110
+ self.coordinator.transport.set_message_handler(self._handle_coordinator_message)
111
+ return self.coordinator
112
+
113
+ def add_agents(self, agents: Union[MCPAgent, Sequence[MCPAgent]]) -> List[MCPAgent]:
114
+ """
115
+ Add one or more agents to the group chat.
116
+
117
+ Args:
118
+ agents: A single MCPAgent or a sequence of MCPAgents
119
+
120
+ Returns:
121
+ List of added agents
122
+
123
+ Example:
124
+ # Add a single agent
125
+ group.add_agents(agent1)
126
+
127
+ # Add multiple agents
128
+ group.add_agents([agent1, agent2, agent3])
129
+
130
+ # Add agents as separate arguments
131
+ group.add_agents(agent1, agent2, agent3)
132
+ """
133
+ if not isinstance(agents, (list, tuple)):
134
+ agents = [agents]
135
+
136
+ added_agents = []
137
+ for agent in agents:
138
+ # Retrieve token if agent was already registered
139
+ token = self.agent_tokens.get(agent.name)
140
+ if not self.server_url:
141
+ raise ValueError("Cannot add agents before connecting. Call connect() first.")
142
+
143
+ # Create transport for the agent, passing its name and token
144
+ agent.transport = HTTPTransport.from_url(self.server_url, agent_name=agent.name, token=token)
145
+
146
+ # Set client mode if needed
147
+ if hasattr(agent, 'client_mode'):
148
+ agent.client_mode = True
149
+
150
+ self.agents.append(agent)
151
+ added_agents.append(agent)
152
+
153
+ return added_agents
154
+
155
+ # Alias for backward compatibility
156
+ add_agent = add_agents
157
+
158
+ async def connect(self):
159
+ """Register all agents and start their processing loops."""
160
+ print("Registering coordinator...")
161
+ coord_task = await self._register_and_start_agent(self.coordinator)
162
+ if not coord_task:
163
+ print("Coordinator registration failed. Aborting connect.")
164
+ return
165
+
166
+ print("Registering agents...")
167
+ tasks = [coord_task] # Start with coordinator task
168
+ for agent in self.agents:
169
+ agent_task = await self._register_and_start_agent(agent)
170
+ if agent_task: # Only add task if registration was successful
171
+ tasks.append(agent_task)
172
+ else:
173
+ print(f"Skipping agent {agent.name} due to registration failure.")
174
+ # Optionally, handle failed agents (e.g., remove from group?)
175
+
176
+ if not tasks:
177
+ print("No agents were successfully registered and started.")
178
+ return
179
+
180
+ print(f"All {len(tasks)} agents registered and started.")
181
+ # Store tasks but don't wait for them - they'll run in the background
182
+ self._agent_tasks = tasks
183
+ print("Group chat ready for task submission.")
184
+
185
+ async def _register_and_start_agent(self, agent: MCPAgent):
186
+ """Register an agent, start its event stream, and its processors."""
187
+ if not agent.transport or not isinstance(agent.transport, HTTPTransport):
188
+ raise ValueError(f"Agent {agent.name} has no valid HTTPTransport defined.")
189
+
190
+ response = await agent.transport.register_agent(agent)
191
+
192
+ # Parse response which may be in {'body': '{...}'} format
193
+ if isinstance(response, dict):
194
+ if 'body' in response:
195
+ # Response is wrapped, parse the body string
196
+ try:
197
+ response = json.loads(response['body'])
198
+ except json.JSONDecodeError:
199
+ print(f"Error parsing agent registration response body: {response}")
200
+
201
+ if response and isinstance(response, dict) and "token" in response:
202
+ token = response["token"]
203
+ self.agent_tokens[agent.name] = token
204
+ agent.transport.token = token
205
+ agent.transport.auth_token = token
206
+ print(f"Agent {agent.name} registered successfully with token.")
207
+
208
+ # Start polling *before* starting the agent's run loop
209
+ await agent.transport.start_polling()
210
+
211
+ # Start agent's main run loop (message processing, etc.)
212
+ # We create the task but don't await it here; the calling function (connect) will gather tasks.
213
+ task = asyncio.create_task(agent.run())
214
+ self._agent_tasks.append(task) # Store the task
215
+ return task # Return the task for potential gathering
216
+ else:
217
+ print(f"Warning: Agent {agent.name} registration failed or did not return a token. Response: {response}")
218
+ # Don't run the agent if registration fails - it won't be able to communicate
219
+ return None # Indicate failure
220
+
221
+ async def submit_task(self, task: Dict[str, Any]) -> None:
222
+ """Submit a task to the group chat."""
223
+ print(f"***** [{self.name}] ENTERING submit_task *****", flush=True) # Ensure entry is logged
224
+ if not self.coordinator:
225
+ raise ValueError("Group chat not connected. Call connect() first.")
226
+
227
+ self.task_results = {} # Reset results for new task submission
228
+ print("\n=== Submitting task to group ===")
229
+
230
+ # Ensure task is in the correct format
231
+ if not isinstance(task, dict) or 'type' not in task:
232
+ task = {'type': 'task', 'content': task}
233
+
234
+ # Store task dependencies from the input task definition
235
+ # We need a dictionary where keys are the step task_ids
236
+ if isinstance(task['content'], dict) and all(isinstance(v, dict) for v in task['content'].values()):
237
+ # If task is already a dict mapping task_ids to task info
238
+ self.task_dependencies = task['content']
239
+ else:
240
+ # If task has a steps list, convert it to a dict
241
+ self.task_dependencies = {step["task_id"]: step for step in task['content'].get("steps", [])}
242
+ print(f"Parsed Step Dependencies: {self.task_dependencies}")
243
+
244
+ # Also store in coordinator instance if it exists
245
+ if self.coordinator:
246
+ # Ensure the coordinator has the dict initialized
247
+ if not hasattr(self.coordinator, 'task_dependencies') or not isinstance(getattr(self.coordinator, 'task_dependencies', None), dict):
248
+ self.coordinator.task_dependencies = {}
249
+ self.coordinator.task_dependencies.update(self.task_dependencies)
250
+
251
+ if not self.coordinator or not self.coordinator.transport:
252
+ print("CRITICAL ERROR: Coordinator is not initialized or has no transport. Cannot submit task.")
253
+ return
254
+
255
+ coordinator_transport = self.coordinator.transport
256
+
257
+ print(f"[DEBUG - {self.name}] Starting submit_task loop over {len(self.task_dependencies)} dependencies.", flush=True)
258
+ print(f"***** [{self.name}] Dependencies Content: {self.task_dependencies} *****", flush=True) # Log content before loop
259
+
260
+ # Assign tasks to agents based on the structure
261
+ # Submit tasks to their respective agents
262
+ for task_id, task_info in self.task_dependencies.items():
263
+ print(f"[DEBUG - {self.name}] Loop Iteration: Processing task_id '{task_id}' for agent '{task_info['agent']}'", flush=True)
264
+ agent_name = task_info["agent"]
265
+ # Create message with all necessary fields including content
266
+ message = {
267
+ "type": "task",
268
+ "task_id": task_id,
269
+ "description": task_info["description"],
270
+ "content": task_info.get("content", {}), # Include task content
271
+ "depends_on": task_info.get("depends_on", []), # Include dependencies
272
+ "reply_to": f"{self.server_url}/message/{self.coordinator.name}" # Full URL for reply
273
+ }
274
+ print(f"Sending task to {agent_name}")
275
+ print(f"Task message: {message}")
276
+ # Use coordinator's transport to send task to agent
277
+ await coordinator_transport.send_message(agent_name, message)
278
+
279
+ print("Task submitted. Waiting for completion...")
280
+
281
+ async def wait_for_completion(self, check_interval: float = 1.0):
282
+ """
283
+ Wait for all tasks to complete.
284
+
285
+ Args:
286
+ check_interval: How often to check for completion in seconds
287
+ """
288
+ if not self.coordinator:
289
+ raise ValueError("Group chat not connected. Call connect() first.")
290
+
291
+ try:
292
+ while True:
293
+ # Check if all tasks have results
294
+ all_completed = True
295
+ # Use the dependencies stored in the coordinator
296
+ for task_id in self.task_dependencies:
297
+ # Check both group chat and coordinator results
298
+ if task_id not in self.task_results and task_id not in self.coordinator.task_results:
299
+ all_completed = False
300
+ print(f"Waiting for task {task_id}...")
301
+ break
302
+
303
+ if all_completed:
304
+ print("\n=== All tasks completed! ===")
305
+ print("\nResults:")
306
+ # Merge results from both sources
307
+ all_results = {**self.task_results, **self.coordinator.task_results}
308
+ for task_id, result in all_results.items():
309
+ print(f"\n{task_id}:")
310
+ print(result)
311
+ break
312
+
313
+ await asyncio.sleep(check_interval)
314
+
315
+ except KeyboardInterrupt:
316
+ print("\nStopping group chat...")
317
+
318
+ async def _handle_coordinator_message(self, message: Dict, message_id: str):
319
+ """Handles messages received by the coordinator's transport."""
320
+ if not self.coordinator: # Ensure coordinator exists
321
+ print("[Coordinator Handler] Error: Coordinator not initialized.")
322
+ return
323
+
324
+ print(f"\n[Coordinator {self.coordinator.name}] Received message: {message}")
325
+
326
+ # Handle messages wrapped in 'body' field
327
+ if isinstance(message, dict) and 'body' in message:
328
+ try:
329
+ if isinstance(message['body'], str):
330
+ message = json.loads(message['body'])
331
+ else:
332
+ message = message['body']
333
+ print(f"[Coordinator {self.coordinator.name}] Unwrapped message body: {message}")
334
+ except json.JSONDecodeError:
335
+ print(f"[Coordinator {self.coordinator.name}] Error decoding message body: {message}")
336
+ return
337
+
338
+ # Look for type and task_id at top level
339
+ msg_type = message.get("type")
340
+ task_id = message.get("task_id")
341
+
342
+ print(f"[Coordinator {self.coordinator.name}] Processing message type '{msg_type}' for task {task_id}")
343
+
344
+ if msg_type in ["result", "task_result"]: # Handle both result types
345
+ result_content = message.get("result") or message.get("description") # Try both fields
346
+ if task_id and result_content is not None:
347
+ print(f"[Coordinator {self.coordinator.name}] Storing result for task {task_id}")
348
+ # Store result in both the group chat and coordinator
349
+ self.task_results[task_id] = result_content
350
+ self.coordinator.task_results[task_id] = result_content
351
+ print(f"[Coordinator {self.coordinator.name}] Stored result: {result_content[:100]}...")
352
+ print(f"[Coordinator {self.coordinator.name}] Current task results: {list(self.task_results.keys())}")
353
+ print(f"[Coordinator {self.coordinator.name}] Current dependencies: {self.task_dependencies}")
354
+
355
+ # Acknowledge the message
356
+ try:
357
+ if message_id: # Only acknowledge if we have a message ID
358
+ await self.coordinator.transport.acknowledge_message(self.coordinator.name, message_id)
359
+ print(f"[Coordinator {self.coordinator.name}] Acknowledged message {message_id}")
360
+ except Exception as e:
361
+ print(f"[Coordinator {self.coordinator.name}] Error acknowledging message {message_id}: {e}")
362
+ else:
363
+ print(f"[Coordinator {self.coordinator.name}] Received invalid result message (missing task_id or result): {message}")
364
+ elif msg_type == "get_result": # Handle get result request
365
+ result = None
366
+ if task_id in self.task_results:
367
+ result = self.task_results[task_id]
368
+ elif task_id in self.coordinator.task_results:
369
+ result = self.coordinator.task_results[task_id]
370
+
371
+ if result:
372
+ print(f"[Coordinator {self.coordinator.name}] Found result for task {task_id}")
373
+ # Send result back
374
+ try:
375
+ await self.coordinator.transport.send_message(
376
+ f"{self.server_url}/message/{message.get('sender', 'unknown')}",
377
+ {
378
+ "type": "task_result",
379
+ "task_id": task_id,
380
+ "result": result
381
+ }
382
+ )
383
+ print(f"[Coordinator {self.coordinator.name}] Sent result for task {task_id}")
384
+ except Exception as e:
385
+ print(f"[Coordinator {self.coordinator.name}] Error sending result: {e}")
386
+ else:
387
+ print(f"[Coordinator {self.coordinator.name}] No result found for task {task_id}")
388
+ else:
389
+ print(f"[Coordinator {self.coordinator.name}] Received unhandled message type '{msg_type}': {message}")
390
+ # Optionally, acknowledge other messages too or handle errors
391
+ try:
392
+ await self.coordinator.transport.acknowledge_message(message_id)
393
+ except Exception as e:
394
+ print(f"[Coordinator {self.coordinator.name}] Error acknowledging message {message_id}: {e}")
395
+
396
+ async def shutdown(self):
397
+ """Gracefully disconnect all agents and cancel their tasks."""
398
+ print(f"Initiating shutdown for {len(self._agent_tasks)} agent tasks...")
399
+
400
+ # 1. Cancel all running agent tasks
401
+ for task in self._agent_tasks:
402
+ if task and not task.done():
403
+ print(f"Cancelling task {task.get_name()}...")
404
+ task.cancel()
405
+
406
+ # Wait for all tasks to be cancelled
407
+ if self._agent_tasks:
408
+ await asyncio.gather(*[t for t in self._agent_tasks if t], return_exceptions=True)
409
+ print("All agent tasks cancelled or finished.")
410
+ self._agent_tasks.clear() # Clear the list of tasks
411
+
412
+ # 2. Disconnect transports for all agents (coordinator + regular agents)
413
+ all_agents = [self.coordinator] + self.agents
414
+ disconnect_tasks = []
415
+ for agent in all_agents:
416
+ if hasattr(agent, 'transport') and hasattr(agent.transport, 'disconnect'):
417
+ print(f"Disconnecting transport for {agent.name}...")
418
+ disconnect_tasks.append(agent.transport.disconnect())
419
+
420
+ if disconnect_tasks:
421
+ await asyncio.gather(*disconnect_tasks, return_exceptions=True)
422
+ print("All agent transports disconnected.")
423
+
424
+ print("Shutdown complete.")