kairo-code 0.1.0__py3-none-any.whl → 0.2.0__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 (50) hide show
  1. kairo/backend/api/agents.py +337 -16
  2. kairo/backend/app.py +84 -4
  3. kairo/backend/config.py +4 -2
  4. kairo/backend/models/agent.py +216 -2
  5. kairo/backend/models/api_key.py +4 -1
  6. kairo/backend/models/task.py +31 -0
  7. kairo/backend/models/user_provider_key.py +26 -0
  8. kairo/backend/schemas/agent.py +249 -2
  9. kairo/backend/schemas/api_key.py +3 -0
  10. kairo/backend/services/agent/__init__.py +52 -0
  11. kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
  12. kairo/backend/services/agent/agent_alerts_service.py +201 -0
  13. kairo/backend/services/agent/agent_commands_service.py +142 -0
  14. kairo/backend/services/agent/agent_crud_service.py +150 -0
  15. kairo/backend/services/agent/agent_events_service.py +103 -0
  16. kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
  17. kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
  18. kairo/backend/services/agent/agent_metrics_service.py +259 -0
  19. kairo/backend/services/agent/agent_service.py +315 -0
  20. kairo/backend/services/agent/agent_setup_service.py +180 -0
  21. kairo/backend/services/agent/constants.py +28 -0
  22. kairo/backend/services/agent_service.py +18 -102
  23. kairo/backend/services/api_key_service.py +23 -3
  24. kairo/backend/services/byok_service.py +204 -0
  25. kairo/backend/services/chat_service.py +398 -63
  26. kairo/backend/services/deep_search_service.py +159 -0
  27. kairo/backend/services/email_service.py +418 -19
  28. kairo/backend/services/few_shot_service.py +223 -0
  29. kairo/backend/services/post_processor.py +261 -0
  30. kairo/backend/services/rag_service.py +150 -0
  31. kairo/backend/services/task_service.py +119 -0
  32. kairo/backend/tests/__init__.py +1 -0
  33. kairo/backend/tests/e2e/__init__.py +1 -0
  34. kairo/backend/tests/e2e/agents/__init__.py +1 -0
  35. kairo/backend/tests/e2e/agents/conftest.py +389 -0
  36. kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
  37. kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
  38. kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
  39. kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
  40. kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
  41. kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
  42. kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
  43. kairo/migrations/versions/010_agent_dashboard.py +246 -0
  44. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
  45. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
  46. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
  47. kairo_migrations/env.py +92 -0
  48. kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
  49. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
  50. {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,119 @@
1
+ import logging
2
+ from datetime import datetime, UTC
3
+
4
+ from sqlalchemy import select, text
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from backend.models.agent import Agent
8
+ from backend.models.task import Task
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class TaskService:
14
+ def __init__(self, db: AsyncSession):
15
+ self.db = db
16
+
17
+ async def create_task(self, user_id: str, input_data: dict, agent_id: str | None = None) -> Task:
18
+ """Create a new task for a user, optionally targeting a specific agent."""
19
+ # If agent_id specified, verify the agent belongs to this user
20
+ if agent_id:
21
+ stmt = select(Agent).where(Agent.id == agent_id, Agent.user_id == user_id)
22
+ result = await self.db.execute(stmt)
23
+ if not result.scalar_one_or_none():
24
+ raise ValueError("Agent not found or not owned by user")
25
+
26
+ task = Task(
27
+ user_id=user_id,
28
+ agent_id=agent_id,
29
+ input=input_data,
30
+ status="pending",
31
+ )
32
+ self.db.add(task)
33
+ await self.db.commit()
34
+ await self.db.refresh(task)
35
+ logger.info("Task created: id=%s user=%s agent=%s", task.id, user_id, agent_id)
36
+ return task
37
+
38
+ async def claim_task(self, agent_id: str, user_id: str) -> Task | None:
39
+ """Claim the oldest pending task for this user's agent.
40
+
41
+ Uses FOR UPDATE SKIP LOCKED to prevent race conditions
42
+ when multiple agents poll simultaneously.
43
+ """
44
+ # Verify agent belongs to this user
45
+ agent_stmt = select(Agent).where(Agent.id == agent_id, Agent.user_id == user_id)
46
+ agent_result = await self.db.execute(agent_stmt)
47
+ agent = agent_result.scalar_one_or_none()
48
+ if not agent:
49
+ return None
50
+
51
+ # Find oldest pending task: either targeted to this agent or unassigned
52
+ stmt = (
53
+ select(Task)
54
+ .where(
55
+ Task.user_id == user_id,
56
+ Task.status == "pending",
57
+ (Task.agent_id == agent_id) | (Task.agent_id.is_(None)),
58
+ )
59
+ .order_by(Task.created_at.asc())
60
+ .limit(1)
61
+ .with_for_update(skip_locked=True)
62
+ )
63
+ result = await self.db.execute(stmt)
64
+ task = result.scalar_one_or_none()
65
+
66
+ if not task:
67
+ return None
68
+
69
+ task.agent_id = agent_id
70
+ task.status = "running"
71
+ task.claimed_at = datetime.now(UTC)
72
+ task.updated_at = datetime.now(UTC)
73
+ await self.db.commit()
74
+ await self.db.refresh(task)
75
+ logger.info("Task claimed: id=%s agent=%s", task.id, agent_id)
76
+ return task
77
+
78
+ async def update_task(
79
+ self, task_id: str, user_id: str, status: str,
80
+ output: dict | None = None, error: str | None = None,
81
+ ) -> Task | None:
82
+ """Update a task's status, output, or error."""
83
+ stmt = select(Task).where(Task.id == task_id, Task.user_id == user_id)
84
+ result = await self.db.execute(stmt)
85
+ task = result.scalar_one_or_none()
86
+ if not task:
87
+ return None
88
+
89
+ task.status = status
90
+ task.updated_at = datetime.now(UTC)
91
+
92
+ if output is not None:
93
+ task.output = output
94
+ if error is not None:
95
+ task.error = error
96
+ if status in ("completed", "failed"):
97
+ task.completed_at = datetime.now(UTC)
98
+
99
+ await self.db.commit()
100
+ await self.db.refresh(task)
101
+ logger.info("Task updated: id=%s status=%s", task_id, status)
102
+ return task
103
+
104
+ async def list_tasks(
105
+ self, user_id: str, status: str | None = None, limit: int = 50,
106
+ ) -> list[Task]:
107
+ """List tasks for a user, optionally filtered by status."""
108
+ stmt = select(Task).where(Task.user_id == user_id)
109
+ if status:
110
+ stmt = stmt.where(Task.status == status)
111
+ stmt = stmt.order_by(Task.created_at.desc()).limit(limit)
112
+ result = await self.db.execute(stmt)
113
+ return list(result.scalars().all())
114
+
115
+ async def get_task(self, task_id: str, user_id: str) -> Task | None:
116
+ """Get a single task by ID, scoped to user."""
117
+ stmt = select(Task).where(Task.id == task_id, Task.user_id == user_id)
118
+ result = await self.db.execute(stmt)
119
+ return result.scalar_one_or_none()
@@ -0,0 +1 @@
1
+ # Tests package
@@ -0,0 +1 @@
1
+ # End-to-end tests package
@@ -0,0 +1 @@
1
+ # Agent end-to-end tests package
@@ -0,0 +1,389 @@
1
+ """
2
+ Shared fixtures for agent end-to-end tests.
3
+
4
+ Provides test database setup, authenticated clients, and factory functions
5
+ for creating test data.
6
+ """
7
+
8
+ import asyncio
9
+ import hashlib
10
+ import secrets
11
+ import uuid
12
+ from datetime import datetime, timedelta, UTC
13
+ from typing import AsyncGenerator, Generator
14
+
15
+ import pytest
16
+ import pytest_asyncio
17
+ from argon2 import PasswordHasher
18
+ from httpx import ASGITransport, AsyncClient
19
+ from sqlalchemy import text
20
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
21
+
22
+ from backend.app import create_app
23
+ from backend.config import settings
24
+ from backend.core.database import Base, get_db
25
+ from backend.models.agent import (
26
+ Agent,
27
+ AgentAlertConfig,
28
+ AgentAlertHistory,
29
+ AgentCommand,
30
+ AgentEvent,
31
+ AgentMetrics1m,
32
+ AgentSetupToken,
33
+ )
34
+ from backend.models.api_key import ApiKey
35
+ from backend.models.user import User
36
+
37
+
38
+ # Use a test database URL (modify for actual test environment)
39
+ TEST_DATABASE_URL = settings.DATABASE_URL.replace("/kairo", "/kairo_test")
40
+
41
+ # Create test engine and session
42
+ test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
43
+ TestAsyncSession = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
44
+
45
+
46
+ @pytest.fixture(scope="session")
47
+ def event_loop() -> Generator:
48
+ """Create an instance of the default event loop for the test session."""
49
+ loop = asyncio.get_event_loop_policy().new_event_loop()
50
+ yield loop
51
+ loop.close()
52
+
53
+
54
+ @pytest_asyncio.fixture(scope="function")
55
+ async def db_session() -> AsyncGenerator[AsyncSession, None]:
56
+ """Create a fresh database session for each test."""
57
+ async with test_engine.begin() as conn:
58
+ await conn.run_sync(Base.metadata.create_all)
59
+
60
+ async with TestAsyncSession() as session:
61
+ yield session
62
+ # Rollback any uncommitted changes
63
+ await session.rollback()
64
+
65
+ # Clean up tables after test
66
+ async with test_engine.begin() as conn:
67
+ await conn.run_sync(Base.metadata.drop_all)
68
+
69
+
70
+ @pytest_asyncio.fixture
71
+ async def app(db_session: AsyncSession):
72
+ """Create a test application with dependency overrides."""
73
+ application = create_app()
74
+
75
+ # Override the database dependency
76
+ async def override_get_db():
77
+ yield db_session
78
+
79
+ application.dependency_overrides[get_db] = override_get_db
80
+
81
+ # Enable agents feature for testing
82
+ original_setting = settings.FEATURE_KAIRO_AGENTS_ENABLED
83
+ settings.FEATURE_KAIRO_AGENTS_ENABLED = True
84
+
85
+ yield application
86
+
87
+ # Restore original setting
88
+ settings.FEATURE_KAIRO_AGENTS_ENABLED = original_setting
89
+ application.dependency_overrides.clear()
90
+
91
+
92
+ @pytest_asyncio.fixture
93
+ async def client(app) -> AsyncGenerator[AsyncClient, None]:
94
+ """Create an async HTTP client for testing."""
95
+ transport = ASGITransport(app=app)
96
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
97
+ yield ac
98
+
99
+
100
+ @pytest_asyncio.fixture
101
+ async def test_user(db_session: AsyncSession) -> User:
102
+ """Create a test user."""
103
+ ph = PasswordHasher()
104
+ user = User(
105
+ id=str(uuid.uuid4()),
106
+ email=f"test_{uuid.uuid4().hex[:8]}@example.com",
107
+ hashed_password=ph.hash("testpassword123"),
108
+ email_verified=True,
109
+ status="active",
110
+ plan="pro",
111
+ )
112
+ db_session.add(user)
113
+ await db_session.commit()
114
+ await db_session.refresh(user)
115
+ return user
116
+
117
+
118
+ @pytest_asyncio.fixture
119
+ async def auth_token(test_user: User) -> str:
120
+ """Generate a valid JWT token for the test user."""
121
+ from backend.core.security import create_access_token
122
+ return create_access_token(test_user.id)
123
+
124
+
125
+ @pytest_asyncio.fixture
126
+ async def auth_headers(auth_token: str) -> dict:
127
+ """Return authorization headers for authenticated requests."""
128
+ return {"Authorization": f"Bearer {auth_token}"}
129
+
130
+
131
+ @pytest_asyncio.fixture
132
+ async def test_agent(db_session: AsyncSession, test_user: User) -> Agent:
133
+ """Create a test agent."""
134
+ agent = Agent(
135
+ id=str(uuid.uuid4()),
136
+ user_id=test_user.id,
137
+ name="Test Agent",
138
+ description="A test agent for e2e tests",
139
+ model_preference="nyx",
140
+ agent_type="sdk",
141
+ state="created",
142
+ status="offline",
143
+ )
144
+ db_session.add(agent)
145
+ await db_session.commit()
146
+ await db_session.refresh(agent)
147
+ return agent
148
+
149
+
150
+ @pytest_asyncio.fixture
151
+ async def online_agent(db_session: AsyncSession, test_user: User) -> Agent:
152
+ """Create an online test agent with connection info."""
153
+ now = datetime.now(UTC)
154
+ agent = Agent(
155
+ id=str(uuid.uuid4()),
156
+ user_id=test_user.id,
157
+ name="Online Test Agent",
158
+ description="An online agent for testing",
159
+ model_preference="nyx",
160
+ agent_type="sdk",
161
+ state="online",
162
+ status="online",
163
+ sdk_version="1.0.0",
164
+ host_info={"hostname": "test-host", "os": "linux"},
165
+ first_connected_at=now - timedelta(hours=1),
166
+ last_online_at=now,
167
+ last_heartbeat_at=now,
168
+ )
169
+ db_session.add(agent)
170
+ await db_session.commit()
171
+ await db_session.refresh(agent)
172
+ return agent
173
+
174
+
175
+ @pytest_asyncio.fixture
176
+ async def test_api_key(db_session: AsyncSession, test_user: User, test_agent: Agent) -> tuple[str, ApiKey]:
177
+ """Create an API key for agent authentication."""
178
+ key_raw = secrets.token_urlsafe(32)
179
+ key = f"sk-kairo-{key_raw}"
180
+ key_prefix = key[:20]
181
+ key_hash = hashlib.sha256(key.encode()).hexdigest()
182
+
183
+ api_key = ApiKey(
184
+ id=str(uuid.uuid4()),
185
+ user_id=test_user.id,
186
+ agent_id=test_agent.id,
187
+ name=f"Agent: {test_agent.name}",
188
+ key_prefix=key_prefix,
189
+ key_hash=key_hash,
190
+ key_type="agent",
191
+ is_active=True,
192
+ )
193
+ db_session.add(api_key)
194
+ await db_session.commit()
195
+ await db_session.refresh(api_key)
196
+ return key, api_key
197
+
198
+
199
+ @pytest_asyncio.fixture
200
+ async def api_key_headers(test_api_key: tuple[str, ApiKey]) -> dict:
201
+ """Return authorization headers for API key authentication."""
202
+ key, _ = test_api_key
203
+ return {"Authorization": f"Bearer {key}"}
204
+
205
+
206
+ class AgentFactory:
207
+ """Factory for creating agent test data."""
208
+
209
+ def __init__(self, db_session: AsyncSession, user_id: str):
210
+ self.db = db_session
211
+ self.user_id = user_id
212
+
213
+ async def create_agent(
214
+ self,
215
+ name: str = "Test Agent",
216
+ state: str = "created",
217
+ **kwargs,
218
+ ) -> Agent:
219
+ """Create an agent with specified attributes."""
220
+ agent = Agent(
221
+ id=str(uuid.uuid4()),
222
+ user_id=self.user_id,
223
+ name=name,
224
+ state=state,
225
+ status=kwargs.get("status", "offline"),
226
+ model_preference=kwargs.get("model_preference", "nyx"),
227
+ agent_type=kwargs.get("agent_type", "sdk"),
228
+ description=kwargs.get("description"),
229
+ system_prompt=kwargs.get("system_prompt"),
230
+ sdk_version=kwargs.get("sdk_version"),
231
+ host_info=kwargs.get("host_info"),
232
+ last_heartbeat_at=kwargs.get("last_heartbeat_at"),
233
+ last_online_at=kwargs.get("last_online_at"),
234
+ first_connected_at=kwargs.get("first_connected_at"),
235
+ )
236
+ self.db.add(agent)
237
+ await self.db.commit()
238
+ await self.db.refresh(agent)
239
+ return agent
240
+
241
+ async def create_setup_token(
242
+ self,
243
+ agent_id: str,
244
+ ttl_minutes: int = 15,
245
+ used: bool = False,
246
+ ) -> tuple[str, AgentSetupToken]:
247
+ """Create a setup token for an agent."""
248
+ token = f"kairo_setup_{secrets.token_urlsafe(32)}"
249
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
250
+ expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
251
+
252
+ setup_token = AgentSetupToken(
253
+ id=str(uuid.uuid4()),
254
+ agent_id=agent_id,
255
+ user_id=self.user_id,
256
+ token_hash=token_hash,
257
+ expires_at=expires_at,
258
+ used=used,
259
+ )
260
+ self.db.add(setup_token)
261
+ await self.db.commit()
262
+ await self.db.refresh(setup_token)
263
+ return token, setup_token
264
+
265
+ async def create_command(
266
+ self,
267
+ agent_id: str,
268
+ command_type: str = "restart",
269
+ status: str = "pending",
270
+ **kwargs,
271
+ ) -> AgentCommand:
272
+ """Create a command for an agent."""
273
+ now = datetime.now(UTC)
274
+ command = AgentCommand(
275
+ id=str(uuid.uuid4()),
276
+ agent_id=agent_id,
277
+ command_type=command_type,
278
+ status=status,
279
+ issued_by=kwargs.get("issued_by", self.user_id),
280
+ payload=kwargs.get("payload"),
281
+ expires_at=kwargs.get("expires_at", now + timedelta(minutes=5)),
282
+ )
283
+ self.db.add(command)
284
+ await self.db.commit()
285
+ await self.db.refresh(command)
286
+ return command
287
+
288
+ async def create_event(
289
+ self,
290
+ agent_id: str,
291
+ event_type: str = "heartbeat",
292
+ **kwargs,
293
+ ) -> AgentEvent:
294
+ """Create an event for an agent."""
295
+ event = AgentEvent(
296
+ id=str(uuid.uuid4()),
297
+ agent_id=agent_id,
298
+ event_type=event_type,
299
+ event_data=kwargs.get("event_data"),
300
+ error_type=kwargs.get("error_type"),
301
+ error_message=kwargs.get("error_message"),
302
+ client_ip=kwargs.get("client_ip"),
303
+ )
304
+ self.db.add(event)
305
+ await self.db.commit()
306
+ await self.db.refresh(event)
307
+ return event
308
+
309
+ async def create_metrics(
310
+ self,
311
+ agent_id: str,
312
+ bucket_time: datetime | None = None,
313
+ **kwargs,
314
+ ) -> AgentMetrics1m:
315
+ """Create metrics for an agent."""
316
+ if bucket_time is None:
317
+ bucket_time = datetime.now(UTC).replace(second=0, microsecond=0)
318
+
319
+ metrics = AgentMetrics1m(
320
+ id=str(uuid.uuid4()),
321
+ agent_id=agent_id,
322
+ bucket_time=bucket_time,
323
+ request_count=kwargs.get("request_count", 10),
324
+ error_count=kwargs.get("error_count", 1),
325
+ input_tokens=kwargs.get("input_tokens", 1000),
326
+ output_tokens=kwargs.get("output_tokens", 500),
327
+ total_latency_ms=kwargs.get("total_latency_ms", 5000),
328
+ tool_calls=kwargs.get("tool_calls", 2),
329
+ )
330
+ self.db.add(metrics)
331
+ await self.db.commit()
332
+ await self.db.refresh(metrics)
333
+ return metrics
334
+
335
+ async def create_alert_config(
336
+ self,
337
+ agent_id: str,
338
+ name: str = "Test Alert",
339
+ **kwargs,
340
+ ) -> AgentAlertConfig:
341
+ """Create an alert configuration for an agent."""
342
+ config = AgentAlertConfig(
343
+ id=str(uuid.uuid4()),
344
+ agent_id=agent_id,
345
+ user_id=self.user_id,
346
+ name=name,
347
+ alert_type=kwargs.get("alert_type", "error_rate"),
348
+ metric=kwargs.get("metric", "error_rate"),
349
+ condition=kwargs.get("condition", "gt"),
350
+ threshold=kwargs.get("threshold", 10.0),
351
+ window_seconds=kwargs.get("window_seconds", 300),
352
+ cooldown_seconds=kwargs.get("cooldown_seconds", 3600),
353
+ severity=kwargs.get("severity", "warning"),
354
+ channels=kwargs.get("channels", ["email"]),
355
+ is_enabled=kwargs.get("is_enabled", True),
356
+ )
357
+ self.db.add(config)
358
+ await self.db.commit()
359
+ await self.db.refresh(config)
360
+ return config
361
+
362
+ async def create_alert_history(
363
+ self,
364
+ config_id: str,
365
+ agent_id: str,
366
+ **kwargs,
367
+ ) -> AgentAlertHistory:
368
+ """Create an alert history entry."""
369
+ history = AgentAlertHistory(
370
+ id=str(uuid.uuid4()),
371
+ config_id=config_id,
372
+ agent_id=agent_id,
373
+ alert_type=kwargs.get("alert_type", "error_rate"),
374
+ severity=kwargs.get("severity", "warning"),
375
+ status=kwargs.get("status", "triggered"),
376
+ message=kwargs.get("message", "Error rate exceeded threshold"),
377
+ trigger_value=kwargs.get("trigger_value", 15.0),
378
+ threshold_value=kwargs.get("threshold_value", 10.0),
379
+ )
380
+ self.db.add(history)
381
+ await self.db.commit()
382
+ await self.db.refresh(history)
383
+ return history
384
+
385
+
386
+ @pytest_asyncio.fixture
387
+ async def agent_factory(db_session: AsyncSession, test_user: User) -> AgentFactory:
388
+ """Provide an agent factory for creating test data."""
389
+ return AgentFactory(db_session, test_user.id)