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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodeTether A2A Executor - Bridges official A2A SDK to our task queue system.
|
|
3
|
+
|
|
4
|
+
This module implements the A2A SDK's AgentExecutor interface to bridge standard
|
|
5
|
+
A2A protocol requests to CodeTether's internal task queue and worker system.
|
|
6
|
+
|
|
7
|
+
The executor allows CodeTether to:
|
|
8
|
+
1. Receive requests via the standard A2A protocol (JSON-RPC over HTTP/SSE)
|
|
9
|
+
2. Route them through our existing task queue with leasing and concurrency control
|
|
10
|
+
3. Distribute work to workers via our SSE push system
|
|
11
|
+
4. Stream results back through the A2A protocol's event system
|
|
12
|
+
|
|
13
|
+
Note: This module uses conditional imports to support both:
|
|
14
|
+
- Full A2A SDK installation (a2a-sdk package)
|
|
15
|
+
- Standalone operation with Protocol-based type stubs
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import uuid
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import (
|
|
28
|
+
TYPE_CHECKING,
|
|
29
|
+
Any,
|
|
30
|
+
Callable,
|
|
31
|
+
Dict,
|
|
32
|
+
List,
|
|
33
|
+
Optional,
|
|
34
|
+
Protocol,
|
|
35
|
+
Union,
|
|
36
|
+
runtime_checkable,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Try to import from the official A2A SDK, fall back to local stubs
|
|
40
|
+
try:
|
|
41
|
+
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
|
42
|
+
from a2a.server.events import EventQueue
|
|
43
|
+
from a2a.types import (
|
|
44
|
+
Artifact,
|
|
45
|
+
DataPart,
|
|
46
|
+
FilePart,
|
|
47
|
+
InternalError,
|
|
48
|
+
InvalidParamsError,
|
|
49
|
+
Message,
|
|
50
|
+
Part,
|
|
51
|
+
Task,
|
|
52
|
+
TaskArtifactUpdateEvent,
|
|
53
|
+
TaskState,
|
|
54
|
+
TaskStatus,
|
|
55
|
+
TaskStatusUpdateEvent,
|
|
56
|
+
TextPart,
|
|
57
|
+
UnsupportedOperationError,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
A2A_SDK_AVAILABLE = True
|
|
61
|
+
except ImportError:
|
|
62
|
+
# A2A SDK not installed - define Protocol-based stubs for type checking
|
|
63
|
+
# These allow the code to be imported and type-checked even without the SDK
|
|
64
|
+
A2A_SDK_AVAILABLE = False
|
|
65
|
+
|
|
66
|
+
class TaskStatus(str, Enum):
|
|
67
|
+
"""A2A Task status enum (stub)."""
|
|
68
|
+
|
|
69
|
+
submitted = 'submitted'
|
|
70
|
+
working = 'working'
|
|
71
|
+
input_required = 'input-required'
|
|
72
|
+
completed = 'completed'
|
|
73
|
+
failed = 'failed'
|
|
74
|
+
canceled = 'canceled'
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class TaskState:
|
|
78
|
+
"""A2A Task state (stub)."""
|
|
79
|
+
|
|
80
|
+
state: TaskStatus
|
|
81
|
+
message: Optional[str] = None
|
|
82
|
+
timestamp: Optional[str] = None
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class TextPart:
|
|
86
|
+
"""A2A Text part (stub)."""
|
|
87
|
+
|
|
88
|
+
text: str
|
|
89
|
+
type: str = 'text'
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class FilePart:
|
|
93
|
+
"""A2A File part (stub)."""
|
|
94
|
+
|
|
95
|
+
file: Dict[str, Any]
|
|
96
|
+
type: str = 'file'
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class DataPart:
|
|
100
|
+
"""A2A Data part (stub)."""
|
|
101
|
+
|
|
102
|
+
data: Any
|
|
103
|
+
mimeType: str = 'application/json'
|
|
104
|
+
type: str = 'data'
|
|
105
|
+
|
|
106
|
+
# Union type for all part types
|
|
107
|
+
Part = Union[TextPart, FilePart, DataPart]
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Message:
|
|
111
|
+
"""A2A Message (stub)."""
|
|
112
|
+
|
|
113
|
+
role: str
|
|
114
|
+
parts: List[Part]
|
|
115
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Task:
|
|
119
|
+
"""A2A Task (stub)."""
|
|
120
|
+
|
|
121
|
+
id: str
|
|
122
|
+
status: TaskStatus
|
|
123
|
+
artifacts: Optional[List[Any]] = None
|
|
124
|
+
history: Optional[List[Any]] = None
|
|
125
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Artifact:
|
|
129
|
+
"""A2A Artifact (stub)."""
|
|
130
|
+
|
|
131
|
+
artifactId: str
|
|
132
|
+
name: str
|
|
133
|
+
parts: List[Part]
|
|
134
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class TaskStatusUpdateEvent:
|
|
138
|
+
"""A2A Task status update event (stub)."""
|
|
139
|
+
|
|
140
|
+
taskId: str
|
|
141
|
+
status: TaskState
|
|
142
|
+
message: Optional[Message] = None
|
|
143
|
+
final: bool = False
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class TaskArtifactUpdateEvent:
|
|
147
|
+
"""A2A Task artifact update event (stub)."""
|
|
148
|
+
|
|
149
|
+
taskId: str
|
|
150
|
+
artifact: Artifact
|
|
151
|
+
|
|
152
|
+
@runtime_checkable
|
|
153
|
+
class RequestContext(Protocol):
|
|
154
|
+
"""Protocol for A2A request context."""
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def task_id(self) -> str:
|
|
158
|
+
"""The task ID."""
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def message(self) -> Message:
|
|
163
|
+
"""The request message."""
|
|
164
|
+
...
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def metadata(self) -> Optional[Dict[str, Any]]:
|
|
168
|
+
"""Optional metadata."""
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
@runtime_checkable
|
|
172
|
+
class EventQueue(Protocol):
|
|
173
|
+
"""Protocol for A2A event queue."""
|
|
174
|
+
|
|
175
|
+
async def enqueue_event(self, event: Any) -> None:
|
|
176
|
+
"""Enqueue an event to be sent to the client."""
|
|
177
|
+
...
|
|
178
|
+
|
|
179
|
+
class AgentExecutor(ABC):
|
|
180
|
+
"""Abstract base class for A2A agent executors (stub)."""
|
|
181
|
+
|
|
182
|
+
@abstractmethod
|
|
183
|
+
async def execute(
|
|
184
|
+
self, context: RequestContext, event_queue: EventQueue
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Execute agent logic."""
|
|
187
|
+
...
|
|
188
|
+
|
|
189
|
+
@abstractmethod
|
|
190
|
+
async def cancel(
|
|
191
|
+
self, context: RequestContext, event_queue: EventQueue
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Cancel a running task."""
|
|
194
|
+
...
|
|
195
|
+
|
|
196
|
+
class InvalidParamsError(Exception):
|
|
197
|
+
"""A2A Invalid params error (stub)."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, message: str, data: Any = None):
|
|
200
|
+
self.message = message
|
|
201
|
+
self.data = data
|
|
202
|
+
super().__init__(message)
|
|
203
|
+
|
|
204
|
+
class InternalError(Exception):
|
|
205
|
+
"""A2A Internal error (stub)."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, message: str, data: Any = None):
|
|
208
|
+
self.message = message
|
|
209
|
+
self.data = data
|
|
210
|
+
super().__init__(message)
|
|
211
|
+
|
|
212
|
+
class UnsupportedOperationError(Exception):
|
|
213
|
+
"""A2A Unsupported operation error (stub)."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, message: str, data: Any = None):
|
|
216
|
+
self.message = message
|
|
217
|
+
self.data = data
|
|
218
|
+
super().__init__(message)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Internal imports
|
|
222
|
+
from .task_queue import TaskQueue, TaskRun, TaskRunStatus, TaskLimitExceeded
|
|
223
|
+
from .models import TaskStatus as InternalTaskStatus
|
|
224
|
+
|
|
225
|
+
logger = logging.getLogger(__name__)
|
|
226
|
+
|
|
227
|
+
# Polling configuration
|
|
228
|
+
DEFAULT_POLL_INTERVAL = 0.5 # seconds
|
|
229
|
+
MAX_POLL_DURATION = 300 # 5 minutes max wait
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class CodetetherExecutionError(Exception):
|
|
233
|
+
"""Base exception for CodeTether executor errors."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, message: str, code: int = -32000, data: Any = None):
|
|
236
|
+
self.message = message
|
|
237
|
+
self.code = code
|
|
238
|
+
self.data = data
|
|
239
|
+
super().__init__(message)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class CodetetherExecutor(AgentExecutor):
|
|
243
|
+
"""
|
|
244
|
+
Bridges A2A protocol requests to CodeTether's task queue and worker system.
|
|
245
|
+
|
|
246
|
+
This allows us to:
|
|
247
|
+
1. Receive requests via standard A2A protocol
|
|
248
|
+
2. Route them through our existing task queue with leasing
|
|
249
|
+
3. Distribute to workers via our SSE push system
|
|
250
|
+
4. Return results back through A2A streaming
|
|
251
|
+
|
|
252
|
+
The executor converts between A2A's Message/Task types and our internal
|
|
253
|
+
TaskRun system, maintaining full compatibility with both protocols.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
task_queue: TaskQueue,
|
|
259
|
+
worker_manager: Optional[Any] = None,
|
|
260
|
+
database: Optional[Any] = None,
|
|
261
|
+
default_user_id: Optional[str] = None,
|
|
262
|
+
default_priority: int = 0,
|
|
263
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
264
|
+
max_poll_duration: float = MAX_POLL_DURATION,
|
|
265
|
+
):
|
|
266
|
+
"""
|
|
267
|
+
Initialize the CodeTether executor.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
task_queue: The TaskQueue instance for enqueuing and tracking tasks
|
|
271
|
+
worker_manager: Optional worker manager for direct worker communication
|
|
272
|
+
database: Optional database connection for task persistence
|
|
273
|
+
default_user_id: Default user ID for tasks without explicit user context
|
|
274
|
+
default_priority: Default priority for new tasks
|
|
275
|
+
poll_interval: How often to poll for task updates (seconds)
|
|
276
|
+
max_poll_duration: Maximum time to wait for task completion (seconds)
|
|
277
|
+
"""
|
|
278
|
+
self.task_queue = task_queue
|
|
279
|
+
self.worker_manager = worker_manager
|
|
280
|
+
self.database = database
|
|
281
|
+
self.default_user_id = default_user_id
|
|
282
|
+
self.default_priority = default_priority
|
|
283
|
+
self.poll_interval = poll_interval
|
|
284
|
+
self.max_poll_duration = max_poll_duration
|
|
285
|
+
|
|
286
|
+
async def execute(
|
|
287
|
+
self, context: RequestContext, event_queue: EventQueue
|
|
288
|
+
) -> None:
|
|
289
|
+
"""
|
|
290
|
+
Execute agent logic by routing to our task queue.
|
|
291
|
+
|
|
292
|
+
This method:
|
|
293
|
+
1. Extracts the message content from the A2A request
|
|
294
|
+
2. Creates an internal task in our PostgreSQL database
|
|
295
|
+
3. Enqueues a task_run for worker processing
|
|
296
|
+
4. Polls for results and streams them back via event_queue
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
context: The A2A request context containing message and task info
|
|
300
|
+
event_queue: Queue for sending events back to the client
|
|
301
|
+
"""
|
|
302
|
+
task_id = context.task_id
|
|
303
|
+
message = context.message
|
|
304
|
+
|
|
305
|
+
logger.info(f'Executing A2A request for task {task_id}')
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
# Extract text content from the A2A message
|
|
309
|
+
prompt = self._extract_text_from_message(message)
|
|
310
|
+
if not prompt:
|
|
311
|
+
raise InvalidParamsError(
|
|
312
|
+
message='Message must contain at least one text part'
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Extract metadata for routing
|
|
316
|
+
metadata = self._extract_metadata(context)
|
|
317
|
+
user_id = metadata.get('user_id', self.default_user_id)
|
|
318
|
+
priority = metadata.get('priority', self.default_priority)
|
|
319
|
+
target_agent = metadata.get('target_agent_name')
|
|
320
|
+
required_capabilities = metadata.get('required_capabilities')
|
|
321
|
+
|
|
322
|
+
# Create the internal task record
|
|
323
|
+
internal_task_id = await self._create_internal_task(
|
|
324
|
+
task_id=task_id,
|
|
325
|
+
prompt=prompt,
|
|
326
|
+
user_id=user_id,
|
|
327
|
+
metadata=metadata,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Enqueue the task run
|
|
331
|
+
task_run = await self._enqueue_task_run(
|
|
332
|
+
task_id=internal_task_id,
|
|
333
|
+
user_id=user_id,
|
|
334
|
+
priority=priority,
|
|
335
|
+
target_agent_name=target_agent,
|
|
336
|
+
required_capabilities=required_capabilities,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
logger.info(
|
|
340
|
+
f'Created task_run {task_run.id} for A2A task {task_id}'
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Send initial working status
|
|
344
|
+
await self._send_status_update(
|
|
345
|
+
event_queue=event_queue,
|
|
346
|
+
task_id=task_id,
|
|
347
|
+
status=TaskStatus.working,
|
|
348
|
+
message='Task queued for processing',
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Poll for results and stream back
|
|
352
|
+
await self._poll_and_stream_results(
|
|
353
|
+
event_queue=event_queue,
|
|
354
|
+
task_id=task_id,
|
|
355
|
+
task_run_id=task_run.id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
except TaskLimitExceeded as e:
|
|
359
|
+
logger.warning(f'Task limit exceeded for task {task_id}: {e}')
|
|
360
|
+
await self._send_error(
|
|
361
|
+
event_queue=event_queue,
|
|
362
|
+
task_id=task_id,
|
|
363
|
+
error_message=str(e),
|
|
364
|
+
error_code=-32002,
|
|
365
|
+
error_data=e.to_dict(),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
except InvalidParamsError:
|
|
369
|
+
raise # Re-raise A2A SDK errors as-is
|
|
370
|
+
|
|
371
|
+
except Exception as e:
|
|
372
|
+
logger.exception(f'Error executing task {task_id}: {e}')
|
|
373
|
+
await self._send_error(
|
|
374
|
+
event_queue=event_queue,
|
|
375
|
+
task_id=task_id,
|
|
376
|
+
error_message=f'Internal execution error: {str(e)}',
|
|
377
|
+
error_code=-32000,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
async def cancel(
|
|
381
|
+
self, context: RequestContext, event_queue: EventQueue
|
|
382
|
+
) -> None:
|
|
383
|
+
"""
|
|
384
|
+
Cancel a running task.
|
|
385
|
+
|
|
386
|
+
This method:
|
|
387
|
+
1. Finds the task_run associated with the A2A task
|
|
388
|
+
2. Attempts to cancel it in our queue
|
|
389
|
+
3. Sends cancellation confirmation via event_queue
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
context: The A2A request context containing task info
|
|
393
|
+
event_queue: Queue for sending events back to the client
|
|
394
|
+
"""
|
|
395
|
+
task_id = context.task_id
|
|
396
|
+
|
|
397
|
+
logger.info(f'Cancelling A2A task {task_id}')
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
# Find the task_run for this A2A task
|
|
401
|
+
task_run = await self._find_task_run_by_external_id(task_id)
|
|
402
|
+
|
|
403
|
+
if not task_run:
|
|
404
|
+
raise InvalidParamsError(
|
|
405
|
+
message=f'No task found with ID {task_id}'
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Check if task is in a cancellable state
|
|
409
|
+
if task_run.status in (
|
|
410
|
+
TaskRunStatus.COMPLETED,
|
|
411
|
+
TaskRunStatus.FAILED,
|
|
412
|
+
TaskRunStatus.CANCELLED,
|
|
413
|
+
):
|
|
414
|
+
logger.info(
|
|
415
|
+
f'Task {task_id} already in terminal state: {task_run.status}'
|
|
416
|
+
)
|
|
417
|
+
await self._send_status_update(
|
|
418
|
+
event_queue=event_queue,
|
|
419
|
+
task_id=task_id,
|
|
420
|
+
status=self._map_internal_to_a2a_status(task_run.status),
|
|
421
|
+
message=f'Task already {task_run.status.value}',
|
|
422
|
+
is_final=True,
|
|
423
|
+
)
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
# Attempt cancellation
|
|
427
|
+
cancelled = await self.task_queue.cancel_run(task_run.id)
|
|
428
|
+
|
|
429
|
+
if cancelled:
|
|
430
|
+
logger.info(f'Successfully cancelled task {task_id}')
|
|
431
|
+
await self._send_status_update(
|
|
432
|
+
event_queue=event_queue,
|
|
433
|
+
task_id=task_id,
|
|
434
|
+
status=TaskStatus.canceled,
|
|
435
|
+
message='Task cancelled successfully',
|
|
436
|
+
is_final=True,
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
# Task may have transitioned to running state
|
|
440
|
+
logger.warning(
|
|
441
|
+
f'Failed to cancel task {task_id} - may be running'
|
|
442
|
+
)
|
|
443
|
+
await self._send_status_update(
|
|
444
|
+
event_queue=event_queue,
|
|
445
|
+
task_id=task_id,
|
|
446
|
+
status=TaskStatus.working,
|
|
447
|
+
message='Task is currently running and cannot be cancelled',
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
except InvalidParamsError:
|
|
451
|
+
raise
|
|
452
|
+
|
|
453
|
+
except Exception as e:
|
|
454
|
+
logger.exception(f'Error cancelling task {task_id}: {e}')
|
|
455
|
+
raise InternalError(message=f'Failed to cancel task: {str(e)}')
|
|
456
|
+
|
|
457
|
+
# -------------------------------------------------------------------------
|
|
458
|
+
# Helper Methods - Message Extraction
|
|
459
|
+
# -------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
def _extract_text_from_message(self, message: Message) -> str:
|
|
462
|
+
"""
|
|
463
|
+
Extract text content from an A2A Message.
|
|
464
|
+
|
|
465
|
+
Concatenates all text parts in the message, preserving order.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
message: The A2A Message to extract text from
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Concatenated text content from all TextParts
|
|
472
|
+
"""
|
|
473
|
+
text_parts = []
|
|
474
|
+
|
|
475
|
+
for part in message.parts:
|
|
476
|
+
if isinstance(part, TextPart):
|
|
477
|
+
text_parts.append(part.text)
|
|
478
|
+
elif hasattr(part, 'text'):
|
|
479
|
+
# Handle dict-like parts that may have text
|
|
480
|
+
text_parts.append(str(part.text))
|
|
481
|
+
|
|
482
|
+
return '\n'.join(text_parts)
|
|
483
|
+
|
|
484
|
+
def _extract_metadata(self, context: RequestContext) -> Dict[str, Any]:
|
|
485
|
+
"""
|
|
486
|
+
Extract routing and configuration metadata from the request context.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
context: The A2A RequestContext
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Dictionary of metadata for task routing and configuration
|
|
493
|
+
"""
|
|
494
|
+
metadata: Dict[str, Any] = {}
|
|
495
|
+
|
|
496
|
+
# Extract from context metadata if available
|
|
497
|
+
if hasattr(context, 'metadata') and context.metadata:
|
|
498
|
+
ctx_meta = context.metadata
|
|
499
|
+
if isinstance(ctx_meta, dict):
|
|
500
|
+
metadata.update(ctx_meta)
|
|
501
|
+
|
|
502
|
+
# Extract from message metadata
|
|
503
|
+
if context.message and hasattr(context.message, 'metadata'):
|
|
504
|
+
msg_meta = context.message.metadata
|
|
505
|
+
if isinstance(msg_meta, dict):
|
|
506
|
+
metadata.update(msg_meta)
|
|
507
|
+
|
|
508
|
+
return metadata
|
|
509
|
+
|
|
510
|
+
# -------------------------------------------------------------------------
|
|
511
|
+
# Helper Methods - Task Management
|
|
512
|
+
# -------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
async def _create_internal_task(
|
|
515
|
+
self,
|
|
516
|
+
task_id: str,
|
|
517
|
+
prompt: str,
|
|
518
|
+
user_id: Optional[str],
|
|
519
|
+
metadata: Dict[str, Any],
|
|
520
|
+
) -> str:
|
|
521
|
+
"""
|
|
522
|
+
Create an internal task record in the database.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
task_id: The A2A task ID (used as external reference)
|
|
526
|
+
prompt: The task prompt/description
|
|
527
|
+
user_id: Optional user ID
|
|
528
|
+
metadata: Additional task metadata
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
The internal task ID
|
|
532
|
+
"""
|
|
533
|
+
if self.database is None:
|
|
534
|
+
# If no database, use the A2A task_id directly
|
|
535
|
+
return task_id
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
async with self.database.acquire() as conn:
|
|
539
|
+
# Check if task already exists (idempotency)
|
|
540
|
+
existing = await conn.fetchrow(
|
|
541
|
+
"SELECT id FROM tasks WHERE id = $1 OR metadata->>'a2a_task_id' = $1",
|
|
542
|
+
task_id,
|
|
543
|
+
)
|
|
544
|
+
if existing:
|
|
545
|
+
return existing['id']
|
|
546
|
+
|
|
547
|
+
# Create new task
|
|
548
|
+
internal_id = str(uuid.uuid4())
|
|
549
|
+
task_metadata = {
|
|
550
|
+
**metadata,
|
|
551
|
+
'a2a_task_id': task_id,
|
|
552
|
+
'source': 'a2a_executor',
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
await conn.execute(
|
|
556
|
+
"""
|
|
557
|
+
INSERT INTO tasks (id, title, description, status, user_id, metadata, created_at, updated_at)
|
|
558
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
|
559
|
+
""",
|
|
560
|
+
internal_id,
|
|
561
|
+
f'A2A Task: {task_id[:8]}',
|
|
562
|
+
prompt[:500] if len(prompt) > 500 else prompt,
|
|
563
|
+
'pending',
|
|
564
|
+
user_id,
|
|
565
|
+
task_metadata,
|
|
566
|
+
datetime.now(timezone.utc),
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
return internal_id
|
|
570
|
+
|
|
571
|
+
except Exception as e:
|
|
572
|
+
logger.error(f'Failed to create internal task: {e}')
|
|
573
|
+
# Fall back to using A2A task_id
|
|
574
|
+
return task_id
|
|
575
|
+
|
|
576
|
+
async def _enqueue_task_run(
|
|
577
|
+
self,
|
|
578
|
+
task_id: str,
|
|
579
|
+
user_id: Optional[str],
|
|
580
|
+
priority: int,
|
|
581
|
+
target_agent_name: Optional[str] = None,
|
|
582
|
+
required_capabilities: Optional[List[str]] = None,
|
|
583
|
+
) -> TaskRun:
|
|
584
|
+
"""
|
|
585
|
+
Enqueue a task run for worker processing.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
task_id: The internal task ID
|
|
589
|
+
user_id: Optional user ID for concurrency limiting
|
|
590
|
+
priority: Task priority (higher = more urgent)
|
|
591
|
+
target_agent_name: Optional specific agent to route to
|
|
592
|
+
required_capabilities: Optional required worker capabilities
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
The created TaskRun
|
|
596
|
+
"""
|
|
597
|
+
return await self.task_queue.enqueue(
|
|
598
|
+
task_id=task_id,
|
|
599
|
+
user_id=user_id,
|
|
600
|
+
priority=priority,
|
|
601
|
+
target_agent_name=target_agent_name,
|
|
602
|
+
required_capabilities=required_capabilities,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
async def _find_task_run_by_external_id(
|
|
606
|
+
self, a2a_task_id: str
|
|
607
|
+
) -> Optional[TaskRun]:
|
|
608
|
+
"""
|
|
609
|
+
Find a task_run by its A2A external task ID.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
a2a_task_id: The A2A task ID
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
The TaskRun if found, None otherwise
|
|
616
|
+
"""
|
|
617
|
+
# First try direct lookup (if A2A task_id was used as internal ID)
|
|
618
|
+
task_run = await self.task_queue.get_run_by_task(a2a_task_id)
|
|
619
|
+
if task_run:
|
|
620
|
+
return task_run
|
|
621
|
+
|
|
622
|
+
# If we have a database, look up via metadata
|
|
623
|
+
if self.database:
|
|
624
|
+
try:
|
|
625
|
+
async with self.database.acquire() as conn:
|
|
626
|
+
row = await conn.fetchrow(
|
|
627
|
+
"""
|
|
628
|
+
SELECT tr.* FROM task_runs tr
|
|
629
|
+
JOIN tasks t ON tr.task_id = t.id
|
|
630
|
+
WHERE t.metadata->>'a2a_task_id' = $1
|
|
631
|
+
ORDER BY tr.created_at DESC
|
|
632
|
+
LIMIT 1
|
|
633
|
+
""",
|
|
634
|
+
a2a_task_id,
|
|
635
|
+
)
|
|
636
|
+
if row:
|
|
637
|
+
return self.task_queue._row_to_task_run(row)
|
|
638
|
+
except Exception as e:
|
|
639
|
+
logger.error(f'Error looking up task by A2A ID: {e}')
|
|
640
|
+
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
# -------------------------------------------------------------------------
|
|
644
|
+
# Helper Methods - Polling and Streaming
|
|
645
|
+
# -------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
async def _poll_and_stream_results(
|
|
648
|
+
self,
|
|
649
|
+
event_queue: EventQueue,
|
|
650
|
+
task_id: str,
|
|
651
|
+
task_run_id: str,
|
|
652
|
+
) -> None:
|
|
653
|
+
"""
|
|
654
|
+
Poll for task completion and stream results back.
|
|
655
|
+
|
|
656
|
+
This method polls the task_run status and streams updates
|
|
657
|
+
back to the client via the event_queue until the task
|
|
658
|
+
reaches a terminal state.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
event_queue: Queue for sending events
|
|
662
|
+
task_id: The A2A task ID
|
|
663
|
+
task_run_id: The internal task_run ID
|
|
664
|
+
"""
|
|
665
|
+
start_time = asyncio.get_event_loop().time()
|
|
666
|
+
last_status = None
|
|
667
|
+
|
|
668
|
+
while True:
|
|
669
|
+
# Check timeout
|
|
670
|
+
elapsed = asyncio.get_event_loop().time() - start_time
|
|
671
|
+
if elapsed > self.max_poll_duration:
|
|
672
|
+
logger.warning(f'Task {task_id} timed out after {elapsed:.1f}s')
|
|
673
|
+
await self._send_status_update(
|
|
674
|
+
event_queue=event_queue,
|
|
675
|
+
task_id=task_id,
|
|
676
|
+
status=TaskStatus.failed,
|
|
677
|
+
message=f'Task timed out after {self.max_poll_duration}s',
|
|
678
|
+
is_final=True,
|
|
679
|
+
)
|
|
680
|
+
return
|
|
681
|
+
|
|
682
|
+
# Get current task run status
|
|
683
|
+
task_run = await self.task_queue.get_run(task_run_id)
|
|
684
|
+
|
|
685
|
+
if not task_run:
|
|
686
|
+
logger.error(f'Task run {task_run_id} not found')
|
|
687
|
+
await self._send_error(
|
|
688
|
+
event_queue=event_queue,
|
|
689
|
+
task_id=task_id,
|
|
690
|
+
error_message='Task run not found',
|
|
691
|
+
error_code=-32001,
|
|
692
|
+
)
|
|
693
|
+
return
|
|
694
|
+
|
|
695
|
+
# Send status update if changed
|
|
696
|
+
if task_run.status != last_status:
|
|
697
|
+
last_status = task_run.status
|
|
698
|
+
|
|
699
|
+
a2a_status = self._map_internal_to_a2a_status(task_run.status)
|
|
700
|
+
is_final = task_run.status in (
|
|
701
|
+
TaskRunStatus.COMPLETED,
|
|
702
|
+
TaskRunStatus.FAILED,
|
|
703
|
+
TaskRunStatus.CANCELLED,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# Build message based on status
|
|
707
|
+
message = self._build_status_message(task_run)
|
|
708
|
+
|
|
709
|
+
await self._send_status_update(
|
|
710
|
+
event_queue=event_queue,
|
|
711
|
+
task_id=task_id,
|
|
712
|
+
status=a2a_status,
|
|
713
|
+
message=message,
|
|
714
|
+
is_final=is_final,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
# If we have a result, send it as an artifact
|
|
718
|
+
if is_final and task_run.result_summary:
|
|
719
|
+
await self._send_result_artifact(
|
|
720
|
+
event_queue=event_queue,
|
|
721
|
+
task_id=task_id,
|
|
722
|
+
result=task_run.result_summary,
|
|
723
|
+
full_result=task_run.result_full,
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
if is_final:
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
# Wait before next poll
|
|
730
|
+
await asyncio.sleep(self.poll_interval)
|
|
731
|
+
|
|
732
|
+
def _build_status_message(self, task_run: TaskRun) -> str:
|
|
733
|
+
"""Build a human-readable status message for a task run."""
|
|
734
|
+
status_messages = {
|
|
735
|
+
TaskRunStatus.QUEUED: 'Task is queued for processing',
|
|
736
|
+
TaskRunStatus.RUNNING: 'Task is being processed by a worker',
|
|
737
|
+
TaskRunStatus.NEEDS_INPUT: 'Task requires additional input',
|
|
738
|
+
TaskRunStatus.COMPLETED: 'Task completed successfully',
|
|
739
|
+
TaskRunStatus.FAILED: task_run.last_error or 'Task failed',
|
|
740
|
+
TaskRunStatus.CANCELLED: 'Task was cancelled',
|
|
741
|
+
}
|
|
742
|
+
return status_messages.get(
|
|
743
|
+
task_run.status, f'Status: {task_run.status.value}'
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# -------------------------------------------------------------------------
|
|
747
|
+
# Helper Methods - Event Sending
|
|
748
|
+
# -------------------------------------------------------------------------
|
|
749
|
+
|
|
750
|
+
async def _send_status_update(
|
|
751
|
+
self,
|
|
752
|
+
event_queue: EventQueue,
|
|
753
|
+
task_id: str,
|
|
754
|
+
status: TaskStatus,
|
|
755
|
+
message: str,
|
|
756
|
+
is_final: bool = False,
|
|
757
|
+
) -> None:
|
|
758
|
+
"""
|
|
759
|
+
Send a task status update event.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
event_queue: Queue for sending events
|
|
763
|
+
task_id: The A2A task ID
|
|
764
|
+
status: The new task status
|
|
765
|
+
message: Human-readable status message
|
|
766
|
+
is_final: Whether this is the final update
|
|
767
|
+
"""
|
|
768
|
+
# Create the status update event
|
|
769
|
+
event = TaskStatusUpdateEvent(
|
|
770
|
+
taskId=task_id,
|
|
771
|
+
status=TaskState(state=status),
|
|
772
|
+
message=Message(
|
|
773
|
+
role='agent',
|
|
774
|
+
parts=[TextPart(text=message)],
|
|
775
|
+
),
|
|
776
|
+
final=is_final,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
await event_queue.enqueue_event(event)
|
|
780
|
+
|
|
781
|
+
async def _send_result_artifact(
|
|
782
|
+
self,
|
|
783
|
+
event_queue: EventQueue,
|
|
784
|
+
task_id: str,
|
|
785
|
+
result: str,
|
|
786
|
+
full_result: Optional[Dict[str, Any]] = None,
|
|
787
|
+
) -> None:
|
|
788
|
+
"""
|
|
789
|
+
Send task result as an artifact.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
event_queue: Queue for sending events
|
|
793
|
+
task_id: The A2A task ID
|
|
794
|
+
result: The result summary text
|
|
795
|
+
full_result: Optional full result data
|
|
796
|
+
"""
|
|
797
|
+
# Build artifact parts
|
|
798
|
+
parts: List[Part] = [TextPart(text=result)]
|
|
799
|
+
|
|
800
|
+
# If we have structured data, add it as a DataPart
|
|
801
|
+
if full_result:
|
|
802
|
+
parts.append(
|
|
803
|
+
DataPart(
|
|
804
|
+
data=full_result,
|
|
805
|
+
mimeType='application/json',
|
|
806
|
+
)
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
artifact = Artifact(
|
|
810
|
+
artifactId=str(uuid.uuid4()),
|
|
811
|
+
name='result',
|
|
812
|
+
parts=parts,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
event = TaskArtifactUpdateEvent(
|
|
816
|
+
taskId=task_id,
|
|
817
|
+
artifact=artifact,
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
await event_queue.enqueue_event(event)
|
|
821
|
+
|
|
822
|
+
async def _send_error(
|
|
823
|
+
self,
|
|
824
|
+
event_queue: EventQueue,
|
|
825
|
+
task_id: str,
|
|
826
|
+
error_message: str,
|
|
827
|
+
error_code: int = -32000,
|
|
828
|
+
error_data: Any = None,
|
|
829
|
+
) -> None:
|
|
830
|
+
"""
|
|
831
|
+
Send an error status update.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
event_queue: Queue for sending events
|
|
835
|
+
task_id: The A2A task ID
|
|
836
|
+
error_message: Error description
|
|
837
|
+
error_code: JSON-RPC error code
|
|
838
|
+
error_data: Optional additional error data
|
|
839
|
+
"""
|
|
840
|
+
# Build error message with details
|
|
841
|
+
full_message = error_message
|
|
842
|
+
if error_data:
|
|
843
|
+
full_message = f'{error_message}\nDetails: {error_data}'
|
|
844
|
+
|
|
845
|
+
await self._send_status_update(
|
|
846
|
+
event_queue=event_queue,
|
|
847
|
+
task_id=task_id,
|
|
848
|
+
status=TaskStatus.failed,
|
|
849
|
+
message=full_message,
|
|
850
|
+
is_final=True,
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
# -------------------------------------------------------------------------
|
|
854
|
+
# Helper Methods - Status Mapping
|
|
855
|
+
# -------------------------------------------------------------------------
|
|
856
|
+
|
|
857
|
+
def _map_internal_to_a2a_status(
|
|
858
|
+
self, internal_status: TaskRunStatus
|
|
859
|
+
) -> TaskStatus:
|
|
860
|
+
"""
|
|
861
|
+
Map internal TaskRunStatus to A2A TaskStatus.
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
internal_status: Our internal status enum
|
|
865
|
+
|
|
866
|
+
Returns:
|
|
867
|
+
The corresponding A2A TaskStatus
|
|
868
|
+
"""
|
|
869
|
+
status_map = {
|
|
870
|
+
TaskRunStatus.QUEUED: TaskStatus.submitted,
|
|
871
|
+
TaskRunStatus.RUNNING: TaskStatus.working,
|
|
872
|
+
TaskRunStatus.NEEDS_INPUT: TaskStatus.input_required,
|
|
873
|
+
TaskRunStatus.COMPLETED: TaskStatus.completed,
|
|
874
|
+
TaskRunStatus.FAILED: TaskStatus.failed,
|
|
875
|
+
TaskRunStatus.CANCELLED: TaskStatus.canceled,
|
|
876
|
+
}
|
|
877
|
+
return status_map.get(internal_status, TaskStatus.working)
|
|
878
|
+
|
|
879
|
+
def _map_a2a_to_internal_status(
|
|
880
|
+
self, a2a_status: TaskStatus
|
|
881
|
+
) -> TaskRunStatus:
|
|
882
|
+
"""
|
|
883
|
+
Map A2A TaskStatus to internal TaskRunStatus.
|
|
884
|
+
|
|
885
|
+
Args:
|
|
886
|
+
a2a_status: The A2A status enum
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
The corresponding internal TaskRunStatus
|
|
890
|
+
"""
|
|
891
|
+
status_map = {
|
|
892
|
+
TaskStatus.submitted: TaskRunStatus.QUEUED,
|
|
893
|
+
TaskStatus.working: TaskRunStatus.RUNNING,
|
|
894
|
+
TaskStatus.input_required: TaskRunStatus.NEEDS_INPUT,
|
|
895
|
+
TaskStatus.completed: TaskRunStatus.COMPLETED,
|
|
896
|
+
TaskStatus.failed: TaskRunStatus.FAILED,
|
|
897
|
+
TaskStatus.canceled: TaskRunStatus.CANCELLED,
|
|
898
|
+
}
|
|
899
|
+
return status_map.get(a2a_status, TaskRunStatus.QUEUED)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
# Factory function for easy instantiation
|
|
903
|
+
def create_codetether_executor(
|
|
904
|
+
task_queue: TaskQueue,
|
|
905
|
+
worker_manager: Optional[Any] = None,
|
|
906
|
+
database: Optional[Any] = None,
|
|
907
|
+
**kwargs,
|
|
908
|
+
) -> CodetetherExecutor:
|
|
909
|
+
"""
|
|
910
|
+
Factory function to create a CodetetherExecutor instance.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
task_queue: The TaskQueue instance
|
|
914
|
+
worker_manager: Optional worker manager
|
|
915
|
+
database: Optional database connection pool
|
|
916
|
+
**kwargs: Additional configuration options
|
|
917
|
+
|
|
918
|
+
Returns:
|
|
919
|
+
Configured CodetetherExecutor instance
|
|
920
|
+
"""
|
|
921
|
+
return CodetetherExecutor(
|
|
922
|
+
task_queue=task_queue,
|
|
923
|
+
worker_manager=worker_manager,
|
|
924
|
+
database=database,
|
|
925
|
+
**kwargs,
|
|
926
|
+
)
|