sunholo 0.143.1__py3-none-any.whl → 0.143.7__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.
- sunholo/__init__.py +4 -1
- sunholo/a2a/__init__.py +27 -0
- sunholo/a2a/agent_card.py +345 -0
- sunholo/a2a/task_manager.py +480 -0
- sunholo/a2a/vac_a2a_agent.py +383 -0
- sunholo/agents/flask/vac_routes.py +256 -19
- sunholo/mcp/__init__.py +11 -2
- sunholo/mcp/mcp_manager.py +66 -28
- sunholo/mcp/vac_mcp_server.py +3 -9
- {sunholo-0.143.1.dist-info → sunholo-0.143.7.dist-info}/METADATA +4 -2
- {sunholo-0.143.1.dist-info → sunholo-0.143.7.dist-info}/RECORD +15 -11
- {sunholo-0.143.1.dist-info → sunholo-0.143.7.dist-info}/WHEEL +0 -0
- {sunholo-0.143.1.dist-info → sunholo-0.143.7.dist-info}/entry_points.txt +0 -0
- {sunholo-0.143.1.dist-info → sunholo-0.143.7.dist-info}/licenses/LICENSE.txt +0 -0
- {sunholo-0.143.1.dist-info → sunholo-0.143.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,383 @@
|
|
1
|
+
# Copyright [2024] [Holosun ApS]
|
2
|
+
#
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
# you may not use this file except in compliance with the License.
|
5
|
+
# You may obtain a copy of the License at
|
6
|
+
#
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
#
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
# See the License for the specific language governing permissions and
|
13
|
+
# limitations under the License.
|
14
|
+
|
15
|
+
"""
|
16
|
+
A2A Agent wrapper for VAC functionality.
|
17
|
+
Implements the Agent-to-Agent protocol for Sunholo VACs.
|
18
|
+
"""
|
19
|
+
|
20
|
+
import asyncio
|
21
|
+
import json
|
22
|
+
from typing import Dict, List, Any, Optional, Callable, AsyncGenerator
|
23
|
+
from ..custom_logging import log
|
24
|
+
from .agent_card import AgentCardGenerator
|
25
|
+
from .task_manager import A2ATaskManager, A2ATask
|
26
|
+
|
27
|
+
try:
|
28
|
+
# Import A2A Python SDK components when available
|
29
|
+
from a2a import Agent, Task, Message
|
30
|
+
A2A_AVAILABLE = True
|
31
|
+
except ImportError:
|
32
|
+
# Create placeholder classes for development
|
33
|
+
Agent = None
|
34
|
+
Task = None
|
35
|
+
Message = None
|
36
|
+
A2A_AVAILABLE = False
|
37
|
+
|
38
|
+
|
39
|
+
class VACA2AAgent:
|
40
|
+
"""
|
41
|
+
A2A Agent implementation for Sunholo VACs.
|
42
|
+
|
43
|
+
This class wraps VAC functionality to be compatible with the
|
44
|
+
Agent-to-Agent protocol, allowing VACs to participate in A2A ecosystems.
|
45
|
+
"""
|
46
|
+
|
47
|
+
def __init__(self,
|
48
|
+
base_url: str,
|
49
|
+
stream_interpreter: Callable,
|
50
|
+
vac_interpreter: Optional[Callable] = None,
|
51
|
+
vac_names: Optional[List[str]] = None,
|
52
|
+
agent_config: Optional[Dict[str, Any]] = None):
|
53
|
+
"""
|
54
|
+
Initialize the A2A agent.
|
55
|
+
|
56
|
+
Args:
|
57
|
+
base_url: Base URL where the agent is hosted
|
58
|
+
stream_interpreter: Function for streaming VAC interactions
|
59
|
+
vac_interpreter: Function for static VAC interactions (optional)
|
60
|
+
vac_names: List of VAC names to expose (discovers all if None)
|
61
|
+
agent_config: Additional agent configuration
|
62
|
+
"""
|
63
|
+
if not A2A_AVAILABLE:
|
64
|
+
log.warning("A2A Python SDK not available. Install with: pip install a2a-python")
|
65
|
+
|
66
|
+
self.base_url = base_url.rstrip('/')
|
67
|
+
self.stream_interpreter = stream_interpreter
|
68
|
+
self.vac_interpreter = vac_interpreter
|
69
|
+
self.agent_config = agent_config or {}
|
70
|
+
|
71
|
+
# Initialize components
|
72
|
+
self.agent_card_generator = AgentCardGenerator(self.base_url)
|
73
|
+
self.task_manager = A2ATaskManager(stream_interpreter, vac_interpreter)
|
74
|
+
|
75
|
+
# Discover VACs
|
76
|
+
self.vac_names = vac_names or self.agent_card_generator._discover_vacs()
|
77
|
+
if not self.vac_names:
|
78
|
+
log.warning("No VACs discovered. Agent will have no skills.")
|
79
|
+
|
80
|
+
# Generate agent card
|
81
|
+
self.agent_card = self.agent_card_generator.generate_agent_card(self.vac_names)
|
82
|
+
|
83
|
+
log.info(f"Initialized A2A agent with {len(self.vac_names)} VACs: {self.vac_names}")
|
84
|
+
|
85
|
+
def get_agent_card(self) -> Dict[str, Any]:
|
86
|
+
"""
|
87
|
+
Get the agent card for A2A discovery.
|
88
|
+
|
89
|
+
Returns:
|
90
|
+
Agent card dictionary
|
91
|
+
"""
|
92
|
+
return self.agent_card
|
93
|
+
|
94
|
+
def get_discovery_endpoints(self) -> Dict[str, str]:
|
95
|
+
"""
|
96
|
+
Get the A2A discovery endpoint paths.
|
97
|
+
|
98
|
+
Returns:
|
99
|
+
Dictionary mapping endpoint names to their paths
|
100
|
+
"""
|
101
|
+
return self.agent_card_generator.generate_discovery_endpoints()
|
102
|
+
|
103
|
+
async def handle_task_send(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
104
|
+
"""
|
105
|
+
Handle A2A task send request.
|
106
|
+
|
107
|
+
Args:
|
108
|
+
request_data: JSON-RPC request data
|
109
|
+
|
110
|
+
Returns:
|
111
|
+
JSON-RPC response data
|
112
|
+
"""
|
113
|
+
try:
|
114
|
+
# Extract parameters from JSON-RPC request
|
115
|
+
params = request_data.get("params", {})
|
116
|
+
skill_name = params.get("skillName")
|
117
|
+
input_data = params.get("input", {})
|
118
|
+
client_metadata = params.get("clientMetadata", {})
|
119
|
+
|
120
|
+
if not skill_name:
|
121
|
+
return self._create_error_response(
|
122
|
+
request_data.get("id"),
|
123
|
+
-32602, # Invalid params
|
124
|
+
"Missing required parameter: skillName"
|
125
|
+
)
|
126
|
+
|
127
|
+
# Validate skill exists
|
128
|
+
available_skills = [skill["name"] for skill in self.agent_card["skills"]]
|
129
|
+
if skill_name not in available_skills:
|
130
|
+
return self._create_error_response(
|
131
|
+
request_data.get("id"),
|
132
|
+
-32601, # Method not found
|
133
|
+
f"Unknown skill: {skill_name}. Available skills: {available_skills}"
|
134
|
+
)
|
135
|
+
|
136
|
+
# Create and start task
|
137
|
+
task = await self.task_manager.create_task(skill_name, input_data, client_metadata)
|
138
|
+
|
139
|
+
# Return task creation response
|
140
|
+
return {
|
141
|
+
"jsonrpc": "2.0",
|
142
|
+
"result": {
|
143
|
+
"taskId": task.task_id,
|
144
|
+
"state": task.state.value,
|
145
|
+
"createdAt": task.created_at.isoformat(),
|
146
|
+
"estimatedDuration": self._estimate_task_duration(skill_name, input_data)
|
147
|
+
},
|
148
|
+
"id": request_data.get("id")
|
149
|
+
}
|
150
|
+
|
151
|
+
except Exception as e:
|
152
|
+
log.error(f"Error handling task send: {e}")
|
153
|
+
return self._create_error_response(
|
154
|
+
request_data.get("id"),
|
155
|
+
-32603, # Internal error
|
156
|
+
f"Internal error: {str(e)}"
|
157
|
+
)
|
158
|
+
|
159
|
+
async def handle_task_send_subscribe(self, request_data: Dict[str, Any]) -> AsyncGenerator[str, None]:
|
160
|
+
"""
|
161
|
+
Handle A2A task send with subscription (SSE).
|
162
|
+
|
163
|
+
Args:
|
164
|
+
request_data: JSON-RPC request data
|
165
|
+
|
166
|
+
Yields:
|
167
|
+
Server-sent event data strings
|
168
|
+
"""
|
169
|
+
try:
|
170
|
+
# First, send the task
|
171
|
+
response = await self.handle_task_send(request_data)
|
172
|
+
|
173
|
+
# Send initial response
|
174
|
+
yield f"data: {json.dumps(response)}\n\n"
|
175
|
+
|
176
|
+
# If task creation failed, stop here
|
177
|
+
if "error" in response:
|
178
|
+
return
|
179
|
+
|
180
|
+
# Get task ID and subscribe to updates
|
181
|
+
task_id = response["result"]["taskId"]
|
182
|
+
|
183
|
+
# Subscribe to task updates
|
184
|
+
async for update in self.task_manager.subscribe_to_task(task_id):
|
185
|
+
if update is None:
|
186
|
+
break
|
187
|
+
|
188
|
+
# Format as SSE
|
189
|
+
sse_data = {
|
190
|
+
"type": "task_update",
|
191
|
+
"taskId": task_id,
|
192
|
+
"data": update
|
193
|
+
}
|
194
|
+
|
195
|
+
yield f"data: {json.dumps(sse_data)}\n\n"
|
196
|
+
|
197
|
+
# Stop if task is complete
|
198
|
+
if update.get("state") in ["completed", "failed", "canceled"]:
|
199
|
+
break
|
200
|
+
|
201
|
+
except Exception as e:
|
202
|
+
log.error(f"Error in task send subscribe: {e}")
|
203
|
+
error_event = {
|
204
|
+
"type": "error",
|
205
|
+
"error": {
|
206
|
+
"code": -32603,
|
207
|
+
"message": f"Internal error: {str(e)}"
|
208
|
+
}
|
209
|
+
}
|
210
|
+
yield f"data: {json.dumps(error_event)}\n\n"
|
211
|
+
|
212
|
+
async def handle_task_get(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
213
|
+
"""
|
214
|
+
Handle A2A task get request.
|
215
|
+
|
216
|
+
Args:
|
217
|
+
request_data: JSON-RPC request data
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
JSON-RPC response data
|
221
|
+
"""
|
222
|
+
try:
|
223
|
+
params = request_data.get("params", {})
|
224
|
+
task_id = params.get("taskId")
|
225
|
+
|
226
|
+
if not task_id:
|
227
|
+
return self._create_error_response(
|
228
|
+
request_data.get("id"),
|
229
|
+
-32602, # Invalid params
|
230
|
+
"Missing required parameter: taskId"
|
231
|
+
)
|
232
|
+
|
233
|
+
task = await self.task_manager.get_task(task_id)
|
234
|
+
if not task:
|
235
|
+
return self._create_error_response(
|
236
|
+
request_data.get("id"),
|
237
|
+
-32602, # Invalid params
|
238
|
+
f"Task not found: {task_id}"
|
239
|
+
)
|
240
|
+
|
241
|
+
return {
|
242
|
+
"jsonrpc": "2.0",
|
243
|
+
"result": task.to_dict(),
|
244
|
+
"id": request_data.get("id")
|
245
|
+
}
|
246
|
+
|
247
|
+
except Exception as e:
|
248
|
+
log.error(f"Error handling task get: {e}")
|
249
|
+
return self._create_error_response(
|
250
|
+
request_data.get("id"),
|
251
|
+
-32603, # Internal error
|
252
|
+
f"Internal error: {str(e)}"
|
253
|
+
)
|
254
|
+
|
255
|
+
async def handle_task_cancel(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
256
|
+
"""
|
257
|
+
Handle A2A task cancel request.
|
258
|
+
|
259
|
+
Args:
|
260
|
+
request_data: JSON-RPC request data
|
261
|
+
|
262
|
+
Returns:
|
263
|
+
JSON-RPC response data
|
264
|
+
"""
|
265
|
+
try:
|
266
|
+
params = request_data.get("params", {})
|
267
|
+
task_id = params.get("taskId")
|
268
|
+
|
269
|
+
if not task_id:
|
270
|
+
return self._create_error_response(
|
271
|
+
request_data.get("id"),
|
272
|
+
-32602, # Invalid params
|
273
|
+
"Missing required parameter: taskId"
|
274
|
+
)
|
275
|
+
|
276
|
+
success = await self.task_manager.cancel_task(task_id)
|
277
|
+
|
278
|
+
if not success:
|
279
|
+
return self._create_error_response(
|
280
|
+
request_data.get("id"),
|
281
|
+
-32602, # Invalid params
|
282
|
+
f"Cannot cancel task: {task_id} (not found or already completed)"
|
283
|
+
)
|
284
|
+
|
285
|
+
task = await self.task_manager.get_task(task_id)
|
286
|
+
|
287
|
+
return {
|
288
|
+
"jsonrpc": "2.0",
|
289
|
+
"result": {
|
290
|
+
"taskId": task_id,
|
291
|
+
"state": task.state.value if task else "canceled",
|
292
|
+
"canceledAt": task.updated_at.isoformat() if task else None
|
293
|
+
},
|
294
|
+
"id": request_data.get("id")
|
295
|
+
}
|
296
|
+
|
297
|
+
except Exception as e:
|
298
|
+
log.error(f"Error handling task cancel: {e}")
|
299
|
+
return self._create_error_response(
|
300
|
+
request_data.get("id"),
|
301
|
+
-32603, # Internal error
|
302
|
+
f"Internal error: {str(e)}"
|
303
|
+
)
|
304
|
+
|
305
|
+
async def handle_push_notification_set(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
306
|
+
"""
|
307
|
+
Handle A2A push notification settings.
|
308
|
+
|
309
|
+
Args:
|
310
|
+
request_data: JSON-RPC request data
|
311
|
+
|
312
|
+
Returns:
|
313
|
+
JSON-RPC response data
|
314
|
+
"""
|
315
|
+
# For now, this is a placeholder
|
316
|
+
# In a full implementation, this would configure push notifications
|
317
|
+
|
318
|
+
return {
|
319
|
+
"jsonrpc": "2.0",
|
320
|
+
"result": {
|
321
|
+
"status": "not_implemented",
|
322
|
+
"message": "Push notifications not yet implemented"
|
323
|
+
},
|
324
|
+
"id": request_data.get("id")
|
325
|
+
}
|
326
|
+
|
327
|
+
def _create_error_response(self, request_id: Any, error_code: int, error_message: str) -> Dict[str, Any]:
|
328
|
+
"""
|
329
|
+
Create a JSON-RPC error response.
|
330
|
+
|
331
|
+
Args:
|
332
|
+
request_id: The request ID
|
333
|
+
error_code: JSON-RPC error code
|
334
|
+
error_message: Error message
|
335
|
+
|
336
|
+
Returns:
|
337
|
+
JSON-RPC error response
|
338
|
+
"""
|
339
|
+
return {
|
340
|
+
"jsonrpc": "2.0",
|
341
|
+
"error": {
|
342
|
+
"code": error_code,
|
343
|
+
"message": error_message
|
344
|
+
},
|
345
|
+
"id": request_id
|
346
|
+
}
|
347
|
+
|
348
|
+
def _estimate_task_duration(self, skill_name: str, input_data: Dict[str, Any]) -> float:
|
349
|
+
"""
|
350
|
+
Estimate task duration in seconds.
|
351
|
+
|
352
|
+
Args:
|
353
|
+
skill_name: Name of the skill
|
354
|
+
input_data: Input parameters
|
355
|
+
|
356
|
+
Returns:
|
357
|
+
Estimated duration in seconds
|
358
|
+
"""
|
359
|
+
# Simple heuristic based on skill type
|
360
|
+
if "stream" in skill_name:
|
361
|
+
return 30.0 # Streaming typically takes longer
|
362
|
+
elif "memory_search" in skill_name:
|
363
|
+
return 5.0 # Memory searches are usually quick
|
364
|
+
else:
|
365
|
+
return 15.0 # Default for queries
|
366
|
+
|
367
|
+
def get_stats(self) -> Dict[str, Any]:
|
368
|
+
"""
|
369
|
+
Get agent statistics.
|
370
|
+
|
371
|
+
Returns:
|
372
|
+
Dictionary with agent statistics
|
373
|
+
"""
|
374
|
+
return {
|
375
|
+
"agent_name": self.agent_card["name"],
|
376
|
+
"vac_count": len(self.vac_names),
|
377
|
+
"skill_count": len(self.agent_card["skills"]),
|
378
|
+
"active_tasks": len([t for t in self.task_manager.tasks.values()
|
379
|
+
if t.state.value in ["submitted", "working"]]),
|
380
|
+
"total_tasks": len(self.task_manager.tasks),
|
381
|
+
"base_url": self.base_url,
|
382
|
+
"a2a_available": A2A_AVAILABLE
|
383
|
+
}
|