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,398 @@
1
+ """
2
+ Email Inbound Webhook Handler for A2A Server.
3
+
4
+ Handles SendGrid Inbound Parse webhooks to enable email reply-based task continuation.
5
+ When a worker sends an email notification (e.g., asking a question or reporting completion),
6
+ users can reply directly to that email and have their response continue the worker's task.
7
+
8
+ Flow:
9
+ 1. Worker sends email with reply-to: task+{session_id}@{inbound_domain}
10
+ 2. User replies to email
11
+ 3. SendGrid Inbound Parse forwards to POST /v1/email/inbound
12
+ 4. This module extracts session_id from "to" address, parses reply text
13
+ 5. Creates a new task with same session_id to continue the conversation
14
+ """
15
+
16
+ import logging
17
+ import os
18
+ import re
19
+ from datetime import datetime
20
+ from typing import Optional, Dict, Any
21
+
22
+ from fastapi import APIRouter, Request, HTTPException, Form, UploadFile, File
23
+ from pydantic import BaseModel
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Router for email inbound endpoints
28
+ email_router = APIRouter(prefix='/v1/email', tags=['email'])
29
+
30
+ # Environment configuration
31
+ EMAIL_INBOUND_DOMAIN = os.environ.get(
32
+ 'EMAIL_INBOUND_DOMAIN', 'inbound.codetether.run'
33
+ )
34
+ EMAIL_REPLY_PREFIX = os.environ.get('EMAIL_REPLY_PREFIX', 'task')
35
+
36
+
37
+ class EmailReplyContext(BaseModel):
38
+ """Parsed context from an inbound email reply."""
39
+
40
+ session_id: Optional[str] = None
41
+ task_id: Optional[str] = None
42
+ codebase_id: Optional[str] = None
43
+ from_email: str
44
+ subject: str
45
+ body_text: str
46
+ body_html: Optional[str] = None
47
+ received_at: datetime
48
+
49
+
50
+ def parse_reply_to_address(to_address: str) -> Dict[str, Optional[str]]:
51
+ """
52
+ Parse the reply-to address to extract session/task context.
53
+
54
+ Expected formats:
55
+ - task+{session_id}@inbound.codetether.run
56
+ - task+{session_id}+{codebase_id}@inbound.codetether.run
57
+
58
+ Returns dict with session_id, task_id, codebase_id (any may be None).
59
+ """
60
+ result: Dict[str, Optional[str]] = {
61
+ 'session_id': None,
62
+ 'task_id': None,
63
+ 'codebase_id': None,
64
+ }
65
+
66
+ # Extract local part before @
67
+ match = re.match(r'^([^@]+)@', to_address.lower().strip())
68
+ if not match:
69
+ return result
70
+
71
+ local_part = match.group(1)
72
+
73
+ # Check if it starts with our prefix
74
+ prefix = EMAIL_REPLY_PREFIX.lower()
75
+ if not local_part.startswith(f'{prefix}+'):
76
+ logger.debug(
77
+ f'Email to address does not match expected prefix: {to_address}'
78
+ )
79
+ return result
80
+
81
+ # Extract the parts after the prefix
82
+ parts_str = local_part[len(prefix) + 1 :] # +1 for the '+'
83
+ parts = parts_str.split('+')
84
+
85
+ if len(parts) >= 1 and parts[0]:
86
+ result['session_id'] = parts[0]
87
+ if len(parts) >= 2 and parts[1]:
88
+ result['codebase_id'] = parts[1]
89
+
90
+ return result
91
+
92
+ local_part = match.group(1)
93
+
94
+ # Check if it starts with our prefix
95
+ prefix = EMAIL_REPLY_PREFIX.lower()
96
+ if not local_part.startswith(f'{prefix}+'):
97
+ logger.debug(
98
+ f'Email to address does not match expected prefix: {to_address}'
99
+ )
100
+ return result
101
+
102
+ # Extract the parts after the prefix
103
+ parts_str = local_part[len(prefix) + 1 :] # +1 for the '+'
104
+ parts = parts_str.split('+')
105
+
106
+ if len(parts) >= 1 and parts[0]:
107
+ result['session_id'] = parts[0]
108
+ if len(parts) >= 2 and parts[1]:
109
+ result['codebase_id'] = parts[1]
110
+
111
+ return result
112
+
113
+
114
+ def extract_reply_body(text: str, html: Optional[str] = None) -> str:
115
+ """
116
+ Extract the actual reply content from an email, removing quoted text.
117
+
118
+ Email clients typically include the original message when replying.
119
+ This function attempts to extract just the new reply content.
120
+ """
121
+ if not text:
122
+ return ''
123
+
124
+ lines = text.split('\n')
125
+ reply_lines = []
126
+
127
+ # Common reply markers
128
+ reply_markers = [
129
+ 'On ', # "On Mon, Jan 11, 2026 at..."
130
+ '-----Original Message-----',
131
+ '________________________________',
132
+ 'From:',
133
+ '> ', # Quoted text
134
+ 'wrote:',
135
+ ]
136
+
137
+ for line in lines:
138
+ stripped = line.strip()
139
+
140
+ # Check if this line starts a quote section
141
+ is_quote_start = False
142
+ for marker in reply_markers:
143
+ if stripped.startswith(marker):
144
+ is_quote_start = True
145
+ break
146
+
147
+ # Also check for "On ... wrote:" pattern spanning multiple words
148
+ if re.match(r'^On .+ wrote:$', stripped):
149
+ is_quote_start = True
150
+
151
+ if is_quote_start:
152
+ # Stop processing - everything after is quoted
153
+ break
154
+
155
+ reply_lines.append(line)
156
+
157
+ # Clean up the result
158
+ result = '\n'.join(reply_lines).strip()
159
+
160
+ # If we got nothing useful, fall back to the original
161
+ if not result or len(result) < 10:
162
+ result = text.strip()
163
+
164
+ return result
165
+
166
+
167
+ def build_reply_to_address(
168
+ session_id: str,
169
+ codebase_id: Optional[str] = None,
170
+ domain: Optional[str] = None,
171
+ ) -> str:
172
+ """
173
+ Build the reply-to address for outbound emails.
174
+
175
+ Format: task+{session_id}@{domain}
176
+ Or: task+{session_id}+{codebase_id}@{domain}
177
+ """
178
+ domain = domain or EMAIL_INBOUND_DOMAIN
179
+ prefix = EMAIL_REPLY_PREFIX
180
+
181
+ if codebase_id:
182
+ return f'{prefix}+{session_id}+{codebase_id}@{domain}'
183
+ return f'{prefix}+{session_id}@{domain}'
184
+
185
+
186
+ @email_router.post('/inbound')
187
+ async def handle_inbound_email(
188
+ request: Request,
189
+ # SendGrid Inbound Parse sends multipart/form-data
190
+ # See: https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook
191
+ to: str = Form(default=''),
192
+ from_: str = Form(default='', alias='from'),
193
+ subject: str = Form(default=''),
194
+ text: str = Form(default=''),
195
+ html: str = Form(default=''),
196
+ sender_ip: str = Form(default=''),
197
+ envelope: str = Form(default=''),
198
+ charsets: str = Form(default=''),
199
+ SPF: str = Form(default=''),
200
+ ):
201
+ """
202
+ Handle inbound email from SendGrid Inbound Parse webhook.
203
+
204
+ This endpoint receives emails sent to {prefix}+{session_id}@{domain}
205
+ and creates a continuation task to resume the worker conversation.
206
+
207
+ SendGrid configuration:
208
+ 1. Go to Settings > Inbound Parse
209
+ 2. Add your domain (e.g., inbound.codetether.run)
210
+ 3. Set destination URL to: https://api.codetether.run/v1/email/inbound
211
+ 4. Enable "POST the raw, full MIME message"
212
+ """
213
+ logger.info(
214
+ f'Received inbound email from {from_} to {to} subject: {subject}'
215
+ )
216
+
217
+ # Parse the reply-to address to get context
218
+ context = parse_reply_to_address(to)
219
+ session_id = context.get('session_id')
220
+ codebase_id = context.get('codebase_id')
221
+
222
+ if not session_id:
223
+ logger.warning(f'Could not extract session_id from to address: {to}')
224
+ # Still accept the webhook to prevent SendGrid retries
225
+ return {
226
+ 'success': False,
227
+ 'error': 'Could not parse session context from address',
228
+ }
229
+
230
+ # Extract the actual reply content (without quoted text)
231
+ reply_body = extract_reply_body(text, html)
232
+
233
+ if not reply_body or len(reply_body.strip()) < 3:
234
+ logger.warning(f'Empty or too short reply body from {from_}')
235
+ return {'success': False, 'error': 'Reply body is empty or too short'}
236
+
237
+ logger.info(
238
+ f'Extracted reply for session {session_id}: {reply_body[:100]}...'
239
+ )
240
+
241
+ # Create the email reply context
242
+ email_context = EmailReplyContext(
243
+ session_id=session_id,
244
+ codebase_id=codebase_id,
245
+ from_email=from_,
246
+ subject=subject,
247
+ body_text=reply_body,
248
+ body_html=html if html else None,
249
+ received_at=datetime.utcnow(),
250
+ )
251
+
252
+ # Create a continuation task
253
+ try:
254
+ task = await create_continuation_task(email_context)
255
+ logger.info(
256
+ f'Created continuation task {task.get("id")} from email reply'
257
+ )
258
+ return {
259
+ 'success': True,
260
+ 'task_id': task.get('id'),
261
+ 'session_id': session_id,
262
+ 'message': 'Reply received and task created',
263
+ }
264
+ except Exception as e:
265
+ logger.error(f'Failed to create continuation task: {e}')
266
+ return {'success': False, 'error': str(e)}
267
+
268
+
269
+ async def create_continuation_task(
270
+ context: EmailReplyContext,
271
+ ) -> Dict[str, Any]:
272
+ """
273
+ Create a new task that continues the conversation from an email reply.
274
+
275
+ The task will use the same session_id so the worker continues
276
+ the existing OpenCode session.
277
+ """
278
+ # Import here to avoid circular imports
279
+ from .monitor_api import get_opencode_bridge
280
+
281
+ bridge = get_opencode_bridge()
282
+ if bridge is None:
283
+ raise RuntimeError('OpenCode bridge not available')
284
+
285
+ # Determine codebase_id - try to look it up from the session if not provided
286
+ codebase_id = context.codebase_id
287
+
288
+ if not codebase_id:
289
+ # Try to find codebase from existing sessions with this session_id
290
+ # This is best-effort - if we can't find it, we'll use a placeholder
291
+ try:
292
+ from . import database as db
293
+
294
+ sessions = await db.db_list_all_sessions(limit=100)
295
+ for session in sessions:
296
+ if session.get('id') == context.session_id:
297
+ codebase_id = session.get('codebase_id')
298
+ break
299
+ except Exception as e:
300
+ logger.debug(f'Could not look up codebase for session: {e}')
301
+
302
+ if not codebase_id:
303
+ # Use 'global' codebase - workers with a global codebase can pick this up
304
+ codebase_id = 'global'
305
+
306
+ # Build the prompt from the email reply
307
+ prompt = f"""[Email Reply from {context.from_email}]
308
+
309
+ Subject: {context.subject}
310
+
311
+ {context.body_text}"""
312
+
313
+ # Create the task with resume_session_id in metadata to continue the conversation
314
+ task = await bridge.create_task(
315
+ codebase_id=codebase_id,
316
+ title=f'Email reply: {context.subject[:50]}',
317
+ prompt=prompt,
318
+ agent_type='build', # Default agent type
319
+ metadata={
320
+ 'source': 'email_reply',
321
+ 'from_email': context.from_email,
322
+ 'original_subject': context.subject,
323
+ 'received_at': context.received_at.isoformat(),
324
+ 'resume_session_id': context.session_id, # Key: resume the existing session
325
+ },
326
+ )
327
+
328
+ if task is None:
329
+ raise RuntimeError('Failed to create continuation task')
330
+
331
+ if hasattr(task, 'to_dict'):
332
+ return task.to_dict()
333
+ elif isinstance(task, dict):
334
+ return task
335
+ else:
336
+ # AgentTask dataclass - convert manually
337
+ return {
338
+ 'id': task.id,
339
+ 'codebase_id': task.codebase_id,
340
+ 'title': task.title,
341
+ 'prompt': task.prompt,
342
+ 'agent_type': task.agent_type,
343
+ 'status': task.status.value
344
+ if hasattr(task.status, 'value')
345
+ else str(task.status),
346
+ 'metadata': task.metadata,
347
+ }
348
+
349
+
350
+ @email_router.get('/test-reply-address')
351
+ async def test_reply_address(
352
+ session_id: str,
353
+ codebase_id: Optional[str] = None,
354
+ ):
355
+ """
356
+ Test endpoint to generate a reply-to address.
357
+
358
+ Useful for debugging the email reply flow.
359
+ """
360
+ address = build_reply_to_address(session_id, codebase_id)
361
+ parsed = parse_reply_to_address(address)
362
+
363
+ return {
364
+ 'reply_to_address': address,
365
+ 'parsed_back': parsed,
366
+ 'domain': EMAIL_INBOUND_DOMAIN,
367
+ 'prefix': EMAIL_REPLY_PREFIX,
368
+ }
369
+
370
+
371
+ @email_router.post('/test-inbound')
372
+ async def test_inbound_parse(
373
+ request: Request,
374
+ ):
375
+ """
376
+ Debug endpoint to inspect raw inbound parse payload.
377
+
378
+ Use this to troubleshoot SendGrid webhook configuration.
379
+ """
380
+ # Get form data
381
+ form_data = await request.form()
382
+
383
+ # Log all fields
384
+ fields = {}
385
+ for key in form_data.keys():
386
+ value = form_data.get(key)
387
+ if isinstance(value, UploadFile):
388
+ fields[key] = f'<UploadFile: {value.filename}>'
389
+ else:
390
+ fields[key] = str(value)[:500] # Truncate long values
391
+
392
+ logger.info(f'Test inbound parse received fields: {list(fields.keys())}')
393
+
394
+ return {
395
+ 'success': True,
396
+ 'fields_received': list(fields.keys()),
397
+ 'sample_data': fields,
398
+ }