codetether 1.2.2__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.
Files changed (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. ui/monitor.js +2662 -0
@@ -0,0 +1,1033 @@
1
+ """
2
+ A2A Protocol Router Integration for FastAPI.
3
+
4
+ Provides a factory function to create an A2A protocol router using the official
5
+ A2A SDK's FastAPI integration. This router handles JSON-RPC and REST bindings
6
+ for the A2A protocol.
7
+
8
+ Endpoints:
9
+ POST /a2a/jsonrpc - JSON-RPC 2.0 endpoint
10
+ POST /a2a/rest/message:send - REST binding for sending messages
11
+ POST /a2a/rest/message:stream - REST binding for streaming messages
12
+ GET /a2a/rest/tasks/{id} - REST binding for getting task status
13
+ GET /a2a/.well-known/agent.json - Agent card discovery
14
+ """
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import uuid
20
+ from abc import ABC, abstractmethod
21
+ from datetime import datetime
22
+ from enum import Enum
23
+ from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Union
24
+
25
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
26
+ from fastapi.responses import JSONResponse, StreamingResponse
27
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
28
+ from pydantic import BaseModel, Field
29
+
30
+ from .agent_card import AgentCard
31
+ from .keycloak_auth import (
32
+ UserSession,
33
+ get_current_user,
34
+ keycloak_auth,
35
+ require_auth,
36
+ )
37
+ from .models import Message, Part, Task, TaskStatus
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Security scheme for Bearer token authentication
42
+ security = HTTPBearer(auto_error=False)
43
+
44
+
45
+ # =============================================================================
46
+ # A2A SDK Compatible Types
47
+ # =============================================================================
48
+
49
+
50
+ class TaskState(str, Enum):
51
+ """Task states as defined by A2A protocol."""
52
+
53
+ PENDING = 'pending'
54
+ WORKING = 'working'
55
+ INPUT_REQUIRED = 'input-required'
56
+ COMPLETED = 'completed'
57
+ CANCELLED = 'cancelled'
58
+ FAILED = 'failed'
59
+
60
+
61
+ class A2AMessage(BaseModel):
62
+ """A2A protocol message."""
63
+
64
+ role: str = Field(..., description='Message role (user/assistant)')
65
+ parts: List[Dict[str, Any]] = Field(default_factory=list)
66
+ metadata: Optional[Dict[str, Any]] = None
67
+
68
+
69
+ class A2ATask(BaseModel):
70
+ """A2A protocol task representation."""
71
+
72
+ id: str
73
+ contextId: Optional[str] = None
74
+ status: Dict[str, Any]
75
+ artifacts: Optional[List[Dict[str, Any]]] = None
76
+ history: Optional[List[Dict[str, Any]]] = None
77
+ metadata: Optional[Dict[str, Any]] = None
78
+
79
+
80
+ class SendMessageParams(BaseModel):
81
+ """Parameters for message/send method."""
82
+
83
+ message: A2AMessage
84
+ configuration: Optional[Dict[str, Any]] = None
85
+
86
+
87
+ class GetTaskParams(BaseModel):
88
+ """Parameters for tasks/get method."""
89
+
90
+ id: str
91
+ historyLength: Optional[int] = None
92
+
93
+
94
+ class CancelTaskParams(BaseModel):
95
+ """Parameters for tasks/cancel method."""
96
+
97
+ id: str
98
+
99
+
100
+ class JSONRPCRequest(BaseModel):
101
+ """JSON-RPC 2.0 request."""
102
+
103
+ jsonrpc: str = '2.0'
104
+ id: Optional[Union[str, int]] = None
105
+ method: str
106
+ params: Optional[Dict[str, Any]] = None
107
+
108
+
109
+ class JSONRPCResponse(BaseModel):
110
+ """JSON-RPC 2.0 response."""
111
+
112
+ jsonrpc: str = '2.0'
113
+ id: Optional[Union[str, int]] = None
114
+ result: Optional[Any] = None
115
+ error: Optional[Dict[str, Any]] = None
116
+
117
+
118
+ class JSONRPCError(BaseModel):
119
+ """JSON-RPC 2.0 error."""
120
+
121
+ code: int
122
+ message: str
123
+ data: Optional[Any] = None
124
+
125
+
126
+ # =============================================================================
127
+ # Task Store Abstraction
128
+ # =============================================================================
129
+
130
+
131
+ class TaskStore(ABC):
132
+ """Abstract base class for task storage."""
133
+
134
+ @abstractmethod
135
+ async def create_task(
136
+ self,
137
+ task_id: Optional[str] = None,
138
+ title: Optional[str] = None,
139
+ prompt: Optional[str] = None,
140
+ ) -> A2ATask:
141
+ """Create a new task."""
142
+ pass
143
+
144
+ @abstractmethod
145
+ async def get_task(self, task_id: str) -> Optional[A2ATask]:
146
+ """Get a task by ID."""
147
+ pass
148
+
149
+ @abstractmethod
150
+ async def update_task(
151
+ self,
152
+ task_id: str,
153
+ status: Optional[Dict[str, Any]] = None,
154
+ artifacts: Optional[List[Dict[str, Any]]] = None,
155
+ ) -> Optional[A2ATask]:
156
+ """Update a task."""
157
+ pass
158
+
159
+ @abstractmethod
160
+ async def cancel_task(self, task_id: str) -> Optional[A2ATask]:
161
+ """Cancel a task."""
162
+ pass
163
+
164
+
165
+ class InMemoryTaskStore(TaskStore):
166
+ """In-memory task store implementation."""
167
+
168
+ def __init__(self):
169
+ self._tasks: Dict[str, A2ATask] = {}
170
+ self._lock = asyncio.Lock()
171
+
172
+ async def create_task(
173
+ self,
174
+ task_id: Optional[str] = None,
175
+ title: Optional[str] = None,
176
+ prompt: Optional[str] = None,
177
+ ) -> A2ATask:
178
+ """Create a new task."""
179
+ if task_id is None:
180
+ task_id = str(uuid.uuid4())
181
+
182
+ task = A2ATask(
183
+ id=task_id,
184
+ status={'state': TaskState.PENDING.value},
185
+ artifacts=[],
186
+ history=[],
187
+ metadata={'title': title, 'prompt': prompt},
188
+ )
189
+
190
+ async with self._lock:
191
+ self._tasks[task_id] = task
192
+
193
+ return task
194
+
195
+ async def get_task(self, task_id: str) -> Optional[A2ATask]:
196
+ """Get a task by ID."""
197
+ async with self._lock:
198
+ return self._tasks.get(task_id)
199
+
200
+ async def update_task(
201
+ self,
202
+ task_id: str,
203
+ status: Optional[Dict[str, Any]] = None,
204
+ artifacts: Optional[List[Dict[str, Any]]] = None,
205
+ ) -> Optional[A2ATask]:
206
+ """Update a task."""
207
+ async with self._lock:
208
+ task = self._tasks.get(task_id)
209
+ if not task:
210
+ return None
211
+
212
+ if status:
213
+ task.status = status
214
+ if artifacts is not None:
215
+ task.artifacts = artifacts
216
+
217
+ return task
218
+
219
+ async def cancel_task(self, task_id: str) -> Optional[A2ATask]:
220
+ """Cancel a task."""
221
+ return await self.update_task(
222
+ task_id, status={'state': TaskState.CANCELLED.value}
223
+ )
224
+
225
+
226
+ class DatabaseTaskStore(TaskStore):
227
+ """Database-backed task store using the existing database module."""
228
+
229
+ def __init__(self, database_url: str):
230
+ self._database_url = database_url
231
+ self._initialized = False
232
+
233
+ async def _ensure_initialized(self):
234
+ """Ensure database connection is ready."""
235
+ if self._initialized:
236
+ return
237
+
238
+ from . import database as db
239
+
240
+ await db.get_pool()
241
+ self._initialized = True
242
+
243
+ async def create_task(
244
+ self,
245
+ task_id: Optional[str] = None,
246
+ title: Optional[str] = None,
247
+ prompt: Optional[str] = None,
248
+ ) -> A2ATask:
249
+ """Create a new task in the database."""
250
+ await self._ensure_initialized()
251
+ from . import database as db
252
+
253
+ if task_id is None:
254
+ task_id = str(uuid.uuid4())
255
+
256
+ if title is None:
257
+ title = f'A2A Task {task_id[:8]}'
258
+
259
+ if prompt is None:
260
+ prompt = 'A2A Protocol message'
261
+
262
+ now = datetime.utcnow()
263
+ pool = await db.get_pool()
264
+ if pool:
265
+ async with pool.acquire() as conn:
266
+ await conn.execute(
267
+ """
268
+ INSERT INTO tasks (id, title, prompt, status, created_at, updated_at)
269
+ VALUES ($1, $2, $3, $4, $5, $6)
270
+ ON CONFLICT (id) DO NOTHING
271
+ """,
272
+ task_id,
273
+ title,
274
+ prompt,
275
+ TaskState.PENDING.value,
276
+ now,
277
+ now,
278
+ )
279
+
280
+ return A2ATask(
281
+ id=task_id,
282
+ status={'state': TaskState.PENDING.value},
283
+ artifacts=[],
284
+ history=[],
285
+ metadata={'title': title},
286
+ )
287
+
288
+ async def get_task(self, task_id: str) -> Optional[A2ATask]:
289
+ """Get a task from the database."""
290
+ await self._ensure_initialized()
291
+ from . import database as db
292
+
293
+ pool = await db.get_pool()
294
+ if not pool:
295
+ return None
296
+
297
+ async with pool.acquire() as conn:
298
+ row = await conn.fetchrow(
299
+ 'SELECT * FROM tasks WHERE id = $1', task_id
300
+ )
301
+
302
+ if not row:
303
+ return None
304
+
305
+ return A2ATask(
306
+ id=row['id'],
307
+ status={'state': row.get('status', TaskState.PENDING.value)},
308
+ artifacts=[],
309
+ history=[],
310
+ metadata={
311
+ 'title': row.get('title'),
312
+ 'description': row.get('description'),
313
+ },
314
+ )
315
+
316
+ async def update_task(
317
+ self,
318
+ task_id: str,
319
+ status: Optional[Dict[str, Any]] = None,
320
+ artifacts: Optional[List[Dict[str, Any]]] = None,
321
+ ) -> Optional[A2ATask]:
322
+ """Update a task in the database."""
323
+ await self._ensure_initialized()
324
+ from . import database as db
325
+
326
+ pool = await db.get_pool()
327
+ if not pool:
328
+ return None
329
+
330
+ now = datetime.utcnow()
331
+ async with pool.acquire() as conn:
332
+ if status:
333
+ await conn.execute(
334
+ """
335
+ UPDATE tasks SET status = $1, updated_at = $2 WHERE id = $3
336
+ """,
337
+ status.get('state', TaskState.WORKING.value),
338
+ now,
339
+ task_id,
340
+ )
341
+
342
+ return await self.get_task(task_id)
343
+
344
+ async def cancel_task(self, task_id: str) -> Optional[A2ATask]:
345
+ """Cancel a task in the database."""
346
+ return await self.update_task(
347
+ task_id, status={'state': TaskState.CANCELLED.value}
348
+ )
349
+
350
+
351
+ # =============================================================================
352
+ # Agent Executor Abstraction
353
+ # =============================================================================
354
+
355
+
356
+ class RequestContext:
357
+ """Context for an A2A request."""
358
+
359
+ def __init__(
360
+ self,
361
+ task_id: Optional[str] = None,
362
+ message: Optional[A2AMessage] = None,
363
+ configuration: Optional[Dict[str, Any]] = None,
364
+ user: Optional[UserSession] = None,
365
+ ):
366
+ self.task_id = task_id
367
+ self.message = message
368
+ self.configuration = configuration or {}
369
+ self.user = user
370
+ self.metadata: Dict[str, Any] = {}
371
+
372
+
373
+ class EventQueue:
374
+ """Queue for streaming events back to the client."""
375
+
376
+ def __init__(self):
377
+ self._queue: asyncio.Queue = asyncio.Queue()
378
+ self._closed = False
379
+
380
+ async def put(self, event: Dict[str, Any]):
381
+ """Put an event on the queue."""
382
+ if not self._closed:
383
+ await self._queue.put(event)
384
+
385
+ async def get(self) -> Optional[Dict[str, Any]]:
386
+ """Get an event from the queue."""
387
+ if self._closed:
388
+ return None
389
+ try:
390
+ return await asyncio.wait_for(self._queue.get(), timeout=30.0)
391
+ except asyncio.TimeoutError:
392
+ return {'type': 'keepalive'}
393
+
394
+ def close(self):
395
+ """Close the queue."""
396
+ self._closed = True
397
+
398
+ @property
399
+ def is_closed(self) -> bool:
400
+ return self._closed
401
+
402
+
403
+ class AgentExecutor(ABC):
404
+ """
405
+ Abstract base class for agent execution logic.
406
+
407
+ Implement this interface to bridge your agent's logic with the A2A protocol.
408
+ The executor receives context about the request and uses an event queue to
409
+ communicate results or updates back.
410
+ """
411
+
412
+ @abstractmethod
413
+ async def execute(
414
+ self,
415
+ context: RequestContext,
416
+ event_queue: EventQueue,
417
+ ) -> None:
418
+ """
419
+ Execute agent logic for a request.
420
+
421
+ Args:
422
+ context: Request context containing the message and configuration
423
+ event_queue: Queue to push events (status updates, artifacts, etc.)
424
+ """
425
+ pass
426
+
427
+ async def cancel(self, task_id: str) -> bool:
428
+ """
429
+ Cancel an in-progress task.
430
+
431
+ Args:
432
+ task_id: The ID of the task to cancel
433
+
434
+ Returns:
435
+ True if cancellation was successful, False otherwise
436
+ """
437
+ return True
438
+
439
+
440
+ class DefaultAgentExecutor(AgentExecutor):
441
+ """Default agent executor that echoes messages back."""
442
+
443
+ async def execute(
444
+ self,
445
+ context: RequestContext,
446
+ event_queue: EventQueue,
447
+ ) -> None:
448
+ """Execute default echo behavior."""
449
+ # Send working status
450
+ await event_queue.put(
451
+ {
452
+ 'type': 'status',
453
+ 'status': {'state': TaskState.WORKING.value},
454
+ }
455
+ )
456
+
457
+ # Echo the message content
458
+ response_text = 'Echo: '
459
+ if context.message:
460
+ for part in context.message.parts:
461
+ if part.get('type') == 'text':
462
+ response_text += part.get('text', '')
463
+
464
+ # Send artifact with response
465
+ await event_queue.put(
466
+ {
467
+ 'type': 'artifact',
468
+ 'artifact': {
469
+ 'parts': [{'type': 'text', 'text': response_text}],
470
+ },
471
+ }
472
+ )
473
+
474
+ # Send completed status
475
+ await event_queue.put(
476
+ {
477
+ 'type': 'status',
478
+ 'status': {'state': TaskState.COMPLETED.value},
479
+ 'final': True,
480
+ }
481
+ )
482
+
483
+
484
+ # =============================================================================
485
+ # Request Handler
486
+ # =============================================================================
487
+
488
+
489
+ class DefaultRequestHandler:
490
+ """
491
+ Default request handler that bridges the A2A protocol with agent executors.
492
+
493
+ Handles JSON-RPC methods and delegates to the appropriate executor methods.
494
+ """
495
+
496
+ def __init__(
497
+ self,
498
+ executor: AgentExecutor,
499
+ task_store: TaskStore,
500
+ ):
501
+ self.executor = executor
502
+ self.task_store = task_store
503
+ self._active_tasks: Dict[str, asyncio.Task] = {}
504
+
505
+ async def handle_send_message(
506
+ self,
507
+ params: Dict[str, Any],
508
+ user: Optional[UserSession] = None,
509
+ ) -> Dict[str, Any]:
510
+ """Handle message/send method."""
511
+ message_data = params.get('message', {})
512
+ message = A2AMessage(
513
+ role=message_data.get('role', 'user'),
514
+ parts=message_data.get('parts', []),
515
+ metadata=message_data.get('metadata'),
516
+ )
517
+
518
+ # Extract text from message parts for prompt
519
+ prompt_text = ''
520
+ for part in message.parts:
521
+ if isinstance(part, dict) and part.get('text'):
522
+ prompt_text += part.get('text', '')
523
+
524
+ # Create task with message as prompt
525
+ task = await self.task_store.create_task(
526
+ prompt=prompt_text or 'A2A message'
527
+ )
528
+
529
+ # Create context and event queue
530
+ context = RequestContext(
531
+ task_id=task.id,
532
+ message=message,
533
+ configuration=params.get('configuration'),
534
+ user=user,
535
+ )
536
+ event_queue = EventQueue()
537
+
538
+ # Execute in background and collect final result
539
+ final_result = {'task': task.model_dump()}
540
+
541
+ async def run_executor():
542
+ try:
543
+ await self.executor.execute(context, event_queue)
544
+ except Exception as e:
545
+ logger.error(f'Executor error: {e}')
546
+ await event_queue.put(
547
+ {
548
+ 'type': 'status',
549
+ 'status': {
550
+ 'state': TaskState.FAILED.value,
551
+ 'message': str(e),
552
+ },
553
+ 'final': True,
554
+ }
555
+ )
556
+ finally:
557
+ event_queue.close()
558
+
559
+ # Run executor
560
+ exec_task = asyncio.create_task(run_executor())
561
+ self._active_tasks[task.id] = exec_task
562
+
563
+ # Collect events until final
564
+ artifacts = []
565
+ final_status = {'state': TaskState.PENDING.value}
566
+
567
+ try:
568
+ while not event_queue.is_closed:
569
+ event = await event_queue.get()
570
+ if event is None:
571
+ break
572
+
573
+ event_type = event.get('type')
574
+ if event_type == 'status':
575
+ final_status = event.get('status', final_status)
576
+ await self.task_store.update_task(
577
+ task.id, status=final_status
578
+ )
579
+ elif event_type == 'artifact':
580
+ artifacts.append(event.get('artifact', {}))
581
+
582
+ if event.get('final'):
583
+ break
584
+ finally:
585
+ self._active_tasks.pop(task.id, None)
586
+
587
+ # Update final task state
588
+ updated_task = await self.task_store.update_task(
589
+ task.id, status=final_status, artifacts=artifacts
590
+ )
591
+
592
+ return {
593
+ 'task': (updated_task or task).model_dump(),
594
+ }
595
+
596
+ async def handle_stream_message(
597
+ self,
598
+ params: Dict[str, Any],
599
+ user: Optional[UserSession] = None,
600
+ ) -> AsyncGenerator[str, None]:
601
+ """Handle message/stream method with SSE."""
602
+ message_data = params.get('message', {})
603
+ message = A2AMessage(
604
+ role=message_data.get('role', 'user'),
605
+ parts=message_data.get('parts', []),
606
+ metadata=message_data.get('metadata'),
607
+ )
608
+
609
+ # Create task
610
+ task = await self.task_store.create_task()
611
+
612
+ # Create context and event queue
613
+ context = RequestContext(
614
+ task_id=task.id,
615
+ message=message,
616
+ configuration=params.get('configuration'),
617
+ user=user,
618
+ )
619
+ event_queue = EventQueue()
620
+
621
+ # Execute in background
622
+ async def run_executor():
623
+ try:
624
+ await self.executor.execute(context, event_queue)
625
+ except Exception as e:
626
+ logger.error(f'Executor error: {e}')
627
+ await event_queue.put(
628
+ {
629
+ 'type': 'status',
630
+ 'status': {
631
+ 'state': TaskState.FAILED.value,
632
+ 'message': str(e),
633
+ },
634
+ 'final': True,
635
+ }
636
+ )
637
+ finally:
638
+ event_queue.close()
639
+
640
+ exec_task = asyncio.create_task(run_executor())
641
+ self._active_tasks[task.id] = exec_task
642
+
643
+ try:
644
+ # Yield initial task event
645
+ yield f'data: {json.dumps({"type": "task", "task": task.model_dump()})}\n\n'
646
+
647
+ while not event_queue.is_closed:
648
+ event = await event_queue.get()
649
+ if event is None:
650
+ break
651
+
652
+ if event.get('type') == 'keepalive':
653
+ yield ':\n\n' # SSE comment for keepalive
654
+ continue
655
+
656
+ yield f'data: {json.dumps(event)}\n\n'
657
+
658
+ if event.get('final'):
659
+ break
660
+
661
+ finally:
662
+ self._active_tasks.pop(task.id, None)
663
+
664
+ async def handle_get_task(
665
+ self,
666
+ params: Dict[str, Any],
667
+ user: Optional[UserSession] = None,
668
+ ) -> Dict[str, Any]:
669
+ """Handle tasks/get method."""
670
+ task_id = params.get('id')
671
+ if not task_id:
672
+ raise ValueError('Task ID is required')
673
+
674
+ task = await self.task_store.get_task(task_id)
675
+ if not task:
676
+ raise ValueError(f'Task not found: {task_id}')
677
+
678
+ return {'task': task.model_dump()}
679
+
680
+ async def handle_cancel_task(
681
+ self,
682
+ params: Dict[str, Any],
683
+ user: Optional[UserSession] = None,
684
+ ) -> Dict[str, Any]:
685
+ """Handle tasks/cancel method."""
686
+ task_id = params.get('id')
687
+ if not task_id:
688
+ raise ValueError('Task ID is required')
689
+
690
+ # Cancel the executor if running
691
+ exec_task = self._active_tasks.get(task_id)
692
+ if exec_task:
693
+ exec_task.cancel()
694
+ self._active_tasks.pop(task_id, None)
695
+
696
+ # Try to cancel via executor
697
+ await self.executor.cancel(task_id)
698
+
699
+ # Update task status
700
+ task = await self.task_store.cancel_task(task_id)
701
+ if not task:
702
+ raise ValueError(f'Task not found: {task_id}')
703
+
704
+ return {'task': task.model_dump()}
705
+
706
+
707
+ # =============================================================================
708
+ # Router Factory
709
+ # =============================================================================
710
+
711
+
712
+ def create_a2a_router(
713
+ executor: Optional[AgentExecutor] = None,
714
+ task_store: Optional[TaskStore] = None,
715
+ agent_card: Optional[AgentCard] = None,
716
+ database_url: Optional[str] = None,
717
+ require_authentication: bool = False,
718
+ auth_callback: Optional[Callable[[str], bool]] = None,
719
+ ) -> APIRouter:
720
+ """
721
+ Create an A2A protocol router with the provided executor.
722
+
723
+ Args:
724
+ executor: AgentExecutor implementation for handling requests.
725
+ Defaults to DefaultAgentExecutor.
726
+ task_store: TaskStore implementation for persisting tasks.
727
+ Defaults to InMemoryTaskStore or DatabaseTaskStore if database_url is provided.
728
+ agent_card: AgentCard for discovery. Optional.
729
+ database_url: Database URL for DatabaseTaskStore. Optional.
730
+ require_authentication: Whether to require authentication for all endpoints.
731
+ auth_callback: Custom authentication callback (token) -> bool.
732
+
733
+ Returns:
734
+ FastAPI APIRouter with A2A protocol endpoints.
735
+ """
736
+ # Initialize executor
737
+ if executor is None:
738
+ executor = DefaultAgentExecutor()
739
+
740
+ # Initialize task store
741
+ if task_store is None:
742
+ if database_url:
743
+ task_store = DatabaseTaskStore(database_url)
744
+ else:
745
+ task_store = InMemoryTaskStore()
746
+
747
+ # Create request handler
748
+ handler = DefaultRequestHandler(executor, task_store)
749
+
750
+ # Create router
751
+ router = APIRouter(prefix='/a2a', tags=['a2a'])
752
+
753
+ # Authentication dependency
754
+ async def get_authenticated_user(
755
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
756
+ ) -> Optional[UserSession]:
757
+ """Get authenticated user if credentials provided."""
758
+ if not credentials:
759
+ if require_authentication:
760
+ raise HTTPException(
761
+ status_code=401, detail='Authentication required'
762
+ )
763
+ return None
764
+
765
+ token = credentials.credentials
766
+
767
+ # Try custom auth callback first
768
+ if auth_callback:
769
+ if not auth_callback(token):
770
+ raise HTTPException(status_code=401, detail='Invalid token')
771
+ return None # Custom auth doesn't provide user session
772
+
773
+ # Try Keycloak auth
774
+ try:
775
+ user = await get_current_user(credentials)
776
+ if user:
777
+ return user
778
+ except HTTPException:
779
+ if require_authentication:
780
+ raise
781
+
782
+ if require_authentication:
783
+ raise HTTPException(status_code=401, detail='Invalid token')
784
+
785
+ return None
786
+
787
+ # JSON-RPC endpoint
788
+ @router.post('/jsonrpc')
789
+ async def handle_jsonrpc(
790
+ request: Request,
791
+ user: Optional[UserSession] = Depends(get_authenticated_user),
792
+ ) -> Response:
793
+ """Handle JSON-RPC 2.0 requests."""
794
+ try:
795
+ body = await request.body()
796
+ try:
797
+ request_data = json.loads(body)
798
+ except json.JSONDecodeError:
799
+ return JSONResponse(
800
+ content=JSONRPCResponse(
801
+ id=None,
802
+ error={'code': -32700, 'message': 'Parse error'},
803
+ ).model_dump(exclude_none=True),
804
+ status_code=400,
805
+ )
806
+
807
+ # Validate JSON-RPC structure
808
+ try:
809
+ rpc_request = JSONRPCRequest.model_validate(request_data)
810
+ except Exception:
811
+ return JSONResponse(
812
+ content=JSONRPCResponse(
813
+ id=request_data.get('id'),
814
+ error={'code': -32600, 'message': 'Invalid Request'},
815
+ ).model_dump(exclude_none=True),
816
+ status_code=400,
817
+ )
818
+
819
+ # Route methods
820
+ method = rpc_request.method
821
+ params = rpc_request.params or {}
822
+
823
+ try:
824
+ if method == 'message/send':
825
+ result = await handler.handle_send_message(params, user)
826
+ elif method == 'message/stream':
827
+ # Return streaming response
828
+ return StreamingResponse(
829
+ handler.handle_stream_message(params, user),
830
+ media_type='text/event-stream',
831
+ headers={
832
+ 'Cache-Control': 'no-cache',
833
+ 'Connection': 'keep-alive',
834
+ 'X-Accel-Buffering': 'no',
835
+ },
836
+ )
837
+ elif method == 'tasks/get':
838
+ result = await handler.handle_get_task(params, user)
839
+ elif method == 'tasks/cancel':
840
+ result = await handler.handle_cancel_task(params, user)
841
+ else:
842
+ return JSONResponse(
843
+ content=JSONRPCResponse(
844
+ id=rpc_request.id,
845
+ error={
846
+ 'code': -32601,
847
+ 'message': 'Method not found',
848
+ },
849
+ ).model_dump(exclude_none=True),
850
+ status_code=400,
851
+ )
852
+
853
+ return JSONResponse(
854
+ content=JSONRPCResponse(
855
+ id=rpc_request.id,
856
+ result=result,
857
+ ).model_dump(exclude_none=True),
858
+ )
859
+
860
+ except ValueError as e:
861
+ return JSONResponse(
862
+ content=JSONRPCResponse(
863
+ id=rpc_request.id,
864
+ error={'code': -32602, 'message': str(e)},
865
+ ).model_dump(exclude_none=True),
866
+ status_code=400,
867
+ )
868
+ except Exception as e:
869
+ logger.error(f'Error handling method {method}: {e}')
870
+ return JSONResponse(
871
+ content=JSONRPCResponse(
872
+ id=rpc_request.id,
873
+ error={
874
+ 'code': -32603,
875
+ 'message': f'Internal error: {str(e)}',
876
+ },
877
+ ).model_dump(exclude_none=True),
878
+ status_code=500,
879
+ )
880
+
881
+ except Exception as e:
882
+ logger.error(f'Error processing JSON-RPC request: {e}')
883
+ return JSONResponse(
884
+ content=JSONRPCResponse(
885
+ id=None,
886
+ error={'code': -32603, 'message': 'Internal error'},
887
+ ).model_dump(exclude_none=True),
888
+ status_code=500,
889
+ )
890
+
891
+ # REST binding: Send message
892
+ @router.post('/rest/message:send')
893
+ async def rest_send_message(
894
+ request: Request,
895
+ user: Optional[UserSession] = Depends(get_authenticated_user),
896
+ ) -> JSONResponse:
897
+ """REST binding for sending messages."""
898
+ try:
899
+ body = await request.json()
900
+ result = await handler.handle_send_message(body, user)
901
+ return JSONResponse(content=result)
902
+ except ValueError as e:
903
+ raise HTTPException(status_code=400, detail=str(e))
904
+ except Exception as e:
905
+ logger.error(f'Error in REST send message: {e}')
906
+ raise HTTPException(status_code=500, detail='Internal server error')
907
+
908
+ # REST binding: Stream message
909
+ @router.post('/rest/message:stream')
910
+ async def rest_stream_message(
911
+ request: Request,
912
+ user: Optional[UserSession] = Depends(get_authenticated_user),
913
+ ) -> StreamingResponse:
914
+ """REST binding for streaming messages."""
915
+ try:
916
+ body = await request.json()
917
+ return StreamingResponse(
918
+ handler.handle_stream_message(body, user),
919
+ media_type='text/event-stream',
920
+ headers={
921
+ 'Cache-Control': 'no-cache',
922
+ 'Connection': 'keep-alive',
923
+ 'X-Accel-Buffering': 'no',
924
+ },
925
+ )
926
+ except ValueError as e:
927
+ raise HTTPException(status_code=400, detail=str(e))
928
+ except Exception as e:
929
+ logger.error(f'Error in REST stream message: {e}')
930
+ raise HTTPException(status_code=500, detail='Internal server error')
931
+
932
+ # REST binding: Get task
933
+ @router.get('/rest/tasks/{task_id}')
934
+ async def rest_get_task(
935
+ task_id: str,
936
+ user: Optional[UserSession] = Depends(get_authenticated_user),
937
+ ) -> JSONResponse:
938
+ """REST binding for getting task status."""
939
+ try:
940
+ result = await handler.handle_get_task({'id': task_id}, user)
941
+ return JSONResponse(content=result)
942
+ except ValueError as e:
943
+ raise HTTPException(status_code=404, detail=str(e))
944
+ except Exception as e:
945
+ logger.error(f'Error in REST get task: {e}')
946
+ raise HTTPException(status_code=500, detail='Internal server error')
947
+
948
+ # REST binding: Cancel task
949
+ @router.post('/rest/tasks/{task_id}:cancel')
950
+ async def rest_cancel_task(
951
+ task_id: str,
952
+ user: Optional[UserSession] = Depends(get_authenticated_user),
953
+ ) -> JSONResponse:
954
+ """REST binding for cancelling a task."""
955
+ try:
956
+ result = await handler.handle_cancel_task({'id': task_id}, user)
957
+ return JSONResponse(content=result)
958
+ except ValueError as e:
959
+ raise HTTPException(status_code=404, detail=str(e))
960
+ except Exception as e:
961
+ logger.error(f'Error in REST cancel task: {e}')
962
+ raise HTTPException(status_code=500, detail='Internal server error')
963
+
964
+ # Agent card discovery endpoint
965
+ if agent_card:
966
+
967
+ @router.get('/.well-known/agent.json')
968
+ async def get_agent_card_endpoint() -> JSONResponse:
969
+ """Get the agent card for discovery."""
970
+ return JSONResponse(content=agent_card.to_dict())
971
+
972
+ return router
973
+
974
+
975
+ # =============================================================================
976
+ # Convenience function for quick setup
977
+ # =============================================================================
978
+
979
+
980
+ def setup_a2a_routes(
981
+ app,
982
+ executor: Optional[AgentExecutor] = None,
983
+ agent_card: Optional[AgentCard] = None,
984
+ database_url: Optional[str] = None,
985
+ require_authentication: bool = False,
986
+ ) -> None:
987
+ """
988
+ Convenience function to setup A2A routes on a FastAPI app.
989
+
990
+ Args:
991
+ app: FastAPI application instance
992
+ executor: AgentExecutor implementation
993
+ agent_card: AgentCard for discovery
994
+ database_url: Database URL for task persistence
995
+ require_authentication: Whether to require auth for all endpoints
996
+ """
997
+ router = create_a2a_router(
998
+ executor=executor,
999
+ agent_card=agent_card,
1000
+ database_url=database_url,
1001
+ require_authentication=require_authentication,
1002
+ )
1003
+ app.include_router(router)
1004
+ logger.info('A2A protocol routes mounted at /a2a')
1005
+
1006
+
1007
+ # =============================================================================
1008
+ # Export public API
1009
+ # =============================================================================
1010
+
1011
+ __all__ = [
1012
+ # Core types
1013
+ 'TaskState',
1014
+ 'A2AMessage',
1015
+ 'A2ATask',
1016
+ 'JSONRPCRequest',
1017
+ 'JSONRPCResponse',
1018
+ 'JSONRPCError',
1019
+ # Task stores
1020
+ 'TaskStore',
1021
+ 'InMemoryTaskStore',
1022
+ 'DatabaseTaskStore',
1023
+ # Executor
1024
+ 'AgentExecutor',
1025
+ 'DefaultAgentExecutor',
1026
+ 'RequestContext',
1027
+ 'EventQueue',
1028
+ # Request handler
1029
+ 'DefaultRequestHandler',
1030
+ # Router factory
1031
+ 'create_a2a_router',
1032
+ 'setup_a2a_routes',
1033
+ ]