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,315 @@
1
+ """
2
+ Main agent service - composes all sub-services.
3
+
4
+ This class maintains backward compatibility with the original monolithic API
5
+ by delegating to focused sub-services while exposing the same interface.
6
+ """
7
+
8
+ import logging
9
+ from datetime import datetime
10
+
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from backend.models.agent import (
14
+ Agent, AgentCommand, AgentAlertConfig, AgentAlertHistory, AgentEvent
15
+ )
16
+ from backend.models.api_key import ApiKey
17
+ from backend.schemas.agent import (
18
+ RegisterAgentRequest, UpdateAgentRequest, AgentHeartbeatRequest,
19
+ CreateAlertConfigRequest, UpdateAlertConfigRequest, IssueCommandRequest,
20
+ TelemetryBatchRequest
21
+ )
22
+ from backend.services.agent.agent_crud_service import AgentCrudService
23
+ from backend.services.agent.agent_heartbeat_service import AgentHeartbeatService
24
+ from backend.services.agent.agent_commands_service import AgentCommandsService
25
+ from backend.services.agent.agent_metrics_service import AgentMetricsService
26
+ from backend.services.agent.agent_alerts_service import AgentAlertsService
27
+ from backend.services.agent.agent_setup_service import AgentSetupService
28
+ from backend.services.agent.agent_events_service import AgentEventsService
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class AgentService:
34
+ """
35
+ Composite agent service maintaining backward compatibility.
36
+
37
+ This class composes all sub-services and delegates calls to them,
38
+ ensuring the existing API contracts remain intact.
39
+ """
40
+
41
+ def __init__(self, db: AsyncSession):
42
+ self.db = db
43
+
44
+ # Initialize sub-services
45
+ self._crud = AgentCrudService(db)
46
+ self._heartbeat = AgentHeartbeatService(db)
47
+ self._commands = AgentCommandsService(db)
48
+ self._metrics = AgentMetricsService(db)
49
+ self._alerts = AgentAlertsService(db)
50
+ self._setup = AgentSetupService(db)
51
+ self._events = AgentEventsService(db)
52
+
53
+ # ─── CRUD Operations ───────────────────────────────────────────────────────
54
+
55
+ async def register(self, user_id: str, req: RegisterAgentRequest) -> Agent:
56
+ """Register a new agent."""
57
+ return await self._crud.register(user_id, req)
58
+
59
+ async def list_agents(self, user_id: str) -> list[Agent]:
60
+ """List all agents for a user."""
61
+ return await self._crud.list_agents(user_id)
62
+
63
+ async def list_agents_with_metrics(self, user_id: str) -> list[dict]:
64
+ """List agents with 24h summary metrics."""
65
+ return await self._crud.list_agents_with_metrics(user_id)
66
+
67
+ async def get_agent(self, user_id: str, agent_id: str) -> Agent | None:
68
+ """Get agent by ID for a specific user."""
69
+ return await self._crud.get_agent(user_id, agent_id)
70
+
71
+ async def get_agent_by_id(self, agent_id: str) -> Agent | None:
72
+ """Get agent by ID only (for SDK auth)."""
73
+ return await self._crud.get_agent_by_id(agent_id)
74
+
75
+ async def update_agent(
76
+ self,
77
+ user_id: str,
78
+ agent_id: str,
79
+ req: UpdateAgentRequest
80
+ ) -> Agent | None:
81
+ """Update agent properties."""
82
+ return await self._crud.update_agent(user_id, agent_id, req)
83
+
84
+ async def delete_agent(self, user_id: str, agent_id: str) -> bool:
85
+ """Soft delete agent."""
86
+ return await self._crud.delete_agent(user_id, agent_id)
87
+
88
+ # ─── Heartbeat ─────────────────────────────────────────────────────────────
89
+
90
+ async def heartbeat(
91
+ self,
92
+ agent_id: str,
93
+ user_id: str,
94
+ req: AgentHeartbeatRequest,
95
+ client_ip: str | None = None
96
+ ) -> tuple[Agent | None, list[dict]]:
97
+ """Process heartbeat and return agent + pending commands."""
98
+ agent = await self.get_agent(user_id, agent_id)
99
+ if not agent:
100
+ return None, []
101
+ return await self._heartbeat.heartbeat(agent, req, client_ip)
102
+
103
+ async def mark_stale_agents_offline(self, threshold_seconds: int = 90) -> int:
104
+ """Mark agents as offline if no heartbeat within threshold."""
105
+ return await self._heartbeat.mark_stale_agents_offline(threshold_seconds)
106
+
107
+ async def mark_stale_agents(self, days: int = 7) -> int:
108
+ """Mark offline agents as stale after days."""
109
+ return await self._heartbeat.mark_stale_agents(days)
110
+
111
+ # ─── Commands ──────────────────────────────────────────────────────────────
112
+
113
+ async def issue_command(
114
+ self,
115
+ user_id: str,
116
+ agent_id: str,
117
+ req: IssueCommandRequest
118
+ ) -> AgentCommand | None:
119
+ """Issue a command to an agent."""
120
+ agent = await self.get_agent(user_id, agent_id)
121
+ if not agent:
122
+ return None
123
+
124
+ command = await self._commands.issue_command(agent, user_id, req)
125
+
126
+ # Log command_issued event
127
+ await self._events.log_event(
128
+ agent_id=agent_id,
129
+ event_type="command_issued",
130
+ event_data={"command_type": req.command_type, "command_id": command.id},
131
+ )
132
+ await self.db.commit()
133
+
134
+ return command
135
+
136
+ async def acknowledge_command(self, agent_id: str, command_id: str) -> bool:
137
+ """Mark command as acknowledged by agent."""
138
+ return await self._commands.acknowledge_command(agent_id, command_id)
139
+
140
+ # ─── Metrics ───────────────────────────────────────────────────────────────
141
+
142
+ async def get_metrics(
143
+ self,
144
+ user_id: str,
145
+ agent_id: str,
146
+ range_str: str = "24h",
147
+ granularity: str = "auto"
148
+ ) -> dict | None:
149
+ """Get agent metrics for a time range."""
150
+ return await self._metrics.get_metrics_for_user(
151
+ user_id, agent_id, range_str, granularity
152
+ )
153
+
154
+ async def process_telemetry_batch(
155
+ self,
156
+ agent_id: str,
157
+ req: TelemetryBatchRequest
158
+ ) -> tuple[int, int, list[str]]:
159
+ """Process a batch of telemetry events."""
160
+ return await self._metrics.process_telemetry_batch(agent_id, req)
161
+
162
+ async def rollup_1m_to_1h(self) -> int:
163
+ """Rollup 1-minute metrics into hourly buckets."""
164
+ return await self._metrics.rollup_1m_to_1h()
165
+
166
+ async def rollup_1h_to_daily(self) -> int:
167
+ """Rollup hourly metrics into daily buckets."""
168
+ return await self._metrics.rollup_1h_to_daily()
169
+
170
+ async def cleanup_old_metrics(
171
+ self,
172
+ retention_days_1m: int = 30,
173
+ retention_days_1h: int = 365
174
+ ) -> tuple[int, int]:
175
+ """Clean up old metrics based on retention policy."""
176
+ return await self._metrics.cleanup_old_metrics(
177
+ retention_days_1m, retention_days_1h
178
+ )
179
+
180
+ # ─── Events ────────────────────────────────────────────────────────────────
181
+
182
+ async def get_events(
183
+ self,
184
+ user_id: str,
185
+ agent_id: str,
186
+ event_type: str | None = None,
187
+ limit: int = 50,
188
+ ) -> list[AgentEvent] | None:
189
+ """Get agent events."""
190
+ return await self._events.get_events_for_user(
191
+ user_id, agent_id, event_type, limit
192
+ )
193
+
194
+ async def cleanup_old_events(self, retention_days: int = 90) -> int:
195
+ """Clean up old events based on retention policy."""
196
+ return await self._events.cleanup_old_events(retention_days)
197
+
198
+ # ─── Setup Tokens ──────────────────────────────────────────────────────────
199
+
200
+ async def generate_setup_token(
201
+ self,
202
+ user_id: str,
203
+ agent_id: str,
204
+ ttl_minutes: int = 15
205
+ ) -> tuple[str, datetime] | None:
206
+ """Generate a one-time setup token."""
207
+ return await self._setup.generate_setup_token_for_user(
208
+ user_id, agent_id, ttl_minutes
209
+ )
210
+
211
+ async def consume_setup_token(
212
+ self,
213
+ token: str,
214
+ client_ip: str | None = None
215
+ ) -> Agent | None:
216
+ """Consume a setup token and return the agent."""
217
+ return await self._setup.consume_setup_token(token, client_ip)
218
+
219
+ async def create_agent_api_key(
220
+ self,
221
+ user_id: str,
222
+ agent_id: str,
223
+ agent_name: str
224
+ ) -> tuple[str, ApiKey]:
225
+ """Create an API key scoped to an agent."""
226
+ return await self._setup.create_agent_api_key(user_id, agent_id, agent_name)
227
+
228
+ # ─── Alerts ────────────────────────────────────────────────────────────────
229
+
230
+ async def create_alert_config(
231
+ self,
232
+ user_id: str,
233
+ agent_id: str,
234
+ req: CreateAlertConfigRequest
235
+ ) -> AgentAlertConfig | None:
236
+ """Create alert configuration."""
237
+ agent = await self.get_agent(user_id, agent_id)
238
+ if not agent:
239
+ return None
240
+ return await self._alerts.create_alert_config(agent_id, user_id, req)
241
+
242
+ async def get_alert_configs(
243
+ self,
244
+ user_id: str,
245
+ agent_id: str
246
+ ) -> list[AgentAlertConfig] | None:
247
+ """Get alert configurations for an agent."""
248
+ return await self._alerts.get_alert_configs_for_user(user_id, agent_id)
249
+
250
+ async def update_alert_config(
251
+ self,
252
+ user_id: str,
253
+ agent_id: str,
254
+ config_id: str,
255
+ req: UpdateAlertConfigRequest
256
+ ) -> AgentAlertConfig | None:
257
+ """Update alert configuration."""
258
+ return await self._alerts.update_alert_config_for_user(
259
+ user_id, agent_id, config_id, req
260
+ )
261
+
262
+ async def delete_alert_config(
263
+ self,
264
+ user_id: str,
265
+ agent_id: str,
266
+ config_id: str
267
+ ) -> bool:
268
+ """Delete alert configuration."""
269
+ return await self._alerts.delete_alert_config_for_user(
270
+ user_id, agent_id, config_id
271
+ )
272
+
273
+ async def get_alert_history(
274
+ self,
275
+ user_id: str,
276
+ agent_id: str,
277
+ limit: int = 50
278
+ ) -> list[AgentAlertHistory] | None:
279
+ """Get alert history for an agent."""
280
+ return await self._alerts.get_alert_history_for_user(user_id, agent_id, limit)
281
+
282
+ async def evaluate_alerts(self) -> int:
283
+ """Evaluate all active alert configs and trigger if conditions met."""
284
+ return await self._alerts.evaluate_alerts()
285
+
286
+ # ─── Internal helpers (for sub-services that need event logging) ──────────
287
+
288
+ async def _log_event(
289
+ self,
290
+ agent_id: str,
291
+ event_type: str,
292
+ event_data: dict | None = None,
293
+ error_type: str | None = None,
294
+ error_message: str | None = None,
295
+ client_ip: str | None = None,
296
+ ) -> None:
297
+ """Log an agent event. For internal use by legacy code paths."""
298
+ await self._events.log_event(
299
+ agent_id=agent_id,
300
+ event_type=event_type,
301
+ event_data=event_data,
302
+ error_type=error_type,
303
+ error_message=error_message,
304
+ client_ip=client_ip,
305
+ )
306
+
307
+ async def _record_metrics(self, agent_id: str, metrics) -> None:
308
+ """Record metrics. For internal use by legacy code paths."""
309
+ # This delegates to heartbeat service's internal method
310
+ # In practice, metrics should be recorded via heartbeat or telemetry
311
+ pass
312
+
313
+ async def _get_pending_commands(self, agent_id: str) -> list[dict]:
314
+ """Get pending commands. For internal use by legacy code paths."""
315
+ return await self._commands.get_pending_commands(agent_id)
@@ -0,0 +1,180 @@
1
+ """
2
+ Agent setup service.
3
+
4
+ Handles setup tokens, registration via tokens, and API key creation.
5
+ """
6
+
7
+ import hashlib
8
+ import logging
9
+ import secrets
10
+ from datetime import datetime, timedelta, UTC
11
+
12
+ from sqlalchemy import select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from backend.models.agent import Agent, AgentSetupToken
16
+ from backend.models.api_key import ApiKey
17
+ from backend.services.agent.constants import DEFAULT_SETUP_TOKEN_TTL_MINUTES
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class AgentSetupService:
23
+ """Service for agent setup and registration."""
24
+
25
+ def __init__(self, db: AsyncSession):
26
+ self.db = db
27
+
28
+ async def generate_setup_token(
29
+ self,
30
+ agent_id: str,
31
+ user_id: str,
32
+ ttl_minutes: int = DEFAULT_SETUP_TOKEN_TTL_MINUTES
33
+ ) -> tuple[str, datetime]:
34
+ """
35
+ Generate a one-time setup token for an agent.
36
+
37
+ Args:
38
+ agent_id: The agent to generate token for
39
+ user_id: The user who owns the agent
40
+ ttl_minutes: Token time-to-live in minutes
41
+
42
+ Returns:
43
+ Tuple of (token, expiration datetime)
44
+ """
45
+ token = f"kairo_setup_{secrets.token_urlsafe(32)}"
46
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
47
+ expires_at = datetime.now(UTC) + timedelta(minutes=ttl_minutes)
48
+
49
+ setup_token = AgentSetupToken(
50
+ agent_id=agent_id,
51
+ user_id=user_id,
52
+ token_hash=token_hash,
53
+ expires_at=expires_at,
54
+ )
55
+ self.db.add(setup_token)
56
+ await self.db.commit()
57
+
58
+ logger.info("Setup token generated: agent=%s", agent_id)
59
+ return token, expires_at
60
+
61
+ async def generate_setup_token_for_user(
62
+ self,
63
+ user_id: str,
64
+ agent_id: str,
65
+ ttl_minutes: int = DEFAULT_SETUP_TOKEN_TTL_MINUTES
66
+ ) -> tuple[str, datetime] | None:
67
+ """
68
+ Generate a setup token for an agent owned by a user.
69
+
70
+ Returns None if the agent is not found or not owned by the user.
71
+ """
72
+ if not await self._verify_agent_ownership(user_id, agent_id):
73
+ return None
74
+ return await self.generate_setup_token(agent_id, user_id, ttl_minutes)
75
+
76
+ async def consume_setup_token(
77
+ self,
78
+ token: str,
79
+ client_ip: str | None = None
80
+ ) -> Agent | None:
81
+ """
82
+ Consume a setup token and return the agent.
83
+
84
+ Marks the token as used and activates the agent.
85
+
86
+ Args:
87
+ token: The setup token to consume
88
+ client_ip: Optional client IP address
89
+
90
+ Returns:
91
+ The agent if token is valid, None otherwise
92
+ """
93
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
94
+ now = datetime.now(UTC)
95
+
96
+ stmt = (
97
+ select(AgentSetupToken)
98
+ .where(AgentSetupToken.token_hash == token_hash)
99
+ .where(AgentSetupToken.used == False)
100
+ .where(AgentSetupToken.expires_at > now)
101
+ )
102
+ result = await self.db.execute(stmt)
103
+ setup_token = result.scalar_one_or_none()
104
+
105
+ if not setup_token:
106
+ return None
107
+
108
+ # Mark as used
109
+ setup_token.used = True
110
+ setup_token.used_at = now
111
+ setup_token.used_from_ip = client_ip
112
+
113
+ # Get and activate agent
114
+ agent = await self._get_agent_by_id(setup_token.agent_id)
115
+ if agent:
116
+ agent.state = "online"
117
+ agent.first_connected_at = now
118
+ agent.last_online_at = now
119
+
120
+ await self.db.commit()
121
+ logger.info("Setup token consumed: agent=%s", setup_token.agent_id)
122
+ return agent
123
+
124
+ async def create_agent_api_key(
125
+ self,
126
+ user_id: str,
127
+ agent_id: str,
128
+ agent_name: str
129
+ ) -> tuple[str, ApiKey]:
130
+ """
131
+ Create an API key scoped to an agent.
132
+
133
+ Args:
134
+ user_id: The user who owns the agent
135
+ agent_id: The agent to create key for
136
+ agent_name: The agent name for key naming
137
+
138
+ Returns:
139
+ Tuple of (raw key, ApiKey model)
140
+ """
141
+ from argon2 import PasswordHasher
142
+
143
+ # Generate key
144
+ key_raw = secrets.token_urlsafe(32)
145
+ key = f"kairo_agent_{key_raw}"
146
+ key_prefix = key[:20]
147
+
148
+ # Hash
149
+ ph = PasswordHasher()
150
+ key_hash = ph.hash(key)
151
+
152
+ api_key = ApiKey(
153
+ user_id=user_id,
154
+ agent_id=agent_id,
155
+ name=f"Agent: {agent_name}",
156
+ key_prefix=key_prefix,
157
+ key_hash=key_hash,
158
+ key_type="agent",
159
+ )
160
+ self.db.add(api_key)
161
+ await self.db.commit()
162
+ await self.db.refresh(api_key)
163
+
164
+ return key, api_key
165
+
166
+ async def _get_agent_by_id(self, agent_id: str) -> Agent | None:
167
+ """Get agent by ID."""
168
+ stmt = select(Agent).where(Agent.id == agent_id).where(Agent.deleted_at.is_(None))
169
+ result = await self.db.execute(stmt)
170
+ return result.scalar_one_or_none()
171
+
172
+ async def _verify_agent_ownership(self, user_id: str, agent_id: str) -> bool:
173
+ """Verify that a user owns an agent."""
174
+ stmt = (
175
+ select(Agent)
176
+ .where(Agent.id == agent_id, Agent.user_id == user_id)
177
+ .where(Agent.deleted_at.is_(None))
178
+ )
179
+ result = await self.db.execute(stmt)
180
+ return result.scalar_one_or_none() is not None
@@ -0,0 +1,28 @@
1
+ """
2
+ Shared constants for agent services.
3
+ """
4
+
5
+ import secrets
6
+
7
+ # Command signing key (in production, load from secrets manager)
8
+ COMMAND_SIGNING_KEY = secrets.token_bytes(32)
9
+
10
+ # Default thresholds
11
+ DEFAULT_HEARTBEAT_TIMEOUT_SECONDS = 90
12
+ DEFAULT_STALE_AGENT_DAYS = 7
13
+ DEFAULT_COMMAND_TTL_MINUTES = 5
14
+ DEFAULT_SETUP_TOKEN_TTL_MINUTES = 15
15
+
16
+ # Retention policies
17
+ DEFAULT_METRICS_1M_RETENTION_DAYS = 30
18
+ DEFAULT_METRICS_1H_RETENTION_DAYS = 365
19
+ DEFAULT_EVENTS_RETENTION_DAYS = 90
20
+
21
+ # Time range mappings for metrics queries
22
+ TIME_RANGE_MAP = {
23
+ "1h": 1, # hours
24
+ "6h": 6, # hours
25
+ "24h": 24, # hours
26
+ "7d": 168, # hours (7 * 24)
27
+ "30d": 720, # hours (30 * 24)
28
+ }
@@ -1,107 +1,23 @@
1
- import json
2
- import logging
3
- from datetime import datetime, UTC
1
+ """
2
+ Agent service - backward compatibility re-export.
4
3
 
5
- from sqlalchemy import select
6
- from sqlalchemy.ext.asyncio import AsyncSession
4
+ This module re-exports the AgentService class and related constants
5
+ from the new modular structure for backward compatibility with existing imports.
7
6
 
8
- from backend.models.agent import Agent
9
- from backend.schemas.agent import RegisterAgentRequest, UpdateAgentRequest
7
+ The actual implementation has been refactored into focused sub-services:
8
+ - agent_crud_service.py - CRUD operations
9
+ - agent_heartbeat_service.py - Heartbeat processing and state management
10
+ - agent_commands_service.py - Command issuing and acknowledgment
11
+ - agent_metrics_service.py - Metrics queries and rollups
12
+ - agent_alerts_service.py - Alert configuration and evaluation
13
+ - agent_setup_service.py - Setup tokens and API key creation
14
+ - agent_events_service.py - Event logging and queries
10
15
 
11
- logger = logging.getLogger(__name__)
16
+ See backend.services.agent for the modular implementation.
17
+ """
12
18
 
19
+ # Re-export for backward compatibility
20
+ from backend.services.agent import AgentService
21
+ from backend.services.agent.constants import COMMAND_SIGNING_KEY
13
22
 
14
- class AgentService:
15
- def __init__(self, db: AsyncSession):
16
- self.db = db
17
-
18
- async def register(self, user_id: str, req: RegisterAgentRequest) -> Agent:
19
- agent = Agent(
20
- user_id=user_id,
21
- name=req.name,
22
- description=req.description,
23
- system_prompt=req.system_prompt,
24
- model_preference=req.model_preference,
25
- tools_config=json.dumps(req.tools) if req.tools else None,
26
- )
27
- self.db.add(agent)
28
- await self.db.commit()
29
- await self.db.refresh(agent)
30
- logger.info("Agent registered: user=%s name=%s id=%s", user_id, req.name, agent.id)
31
- return agent
32
-
33
- async def list_agents(self, user_id: str) -> list[Agent]:
34
- stmt = (
35
- select(Agent)
36
- .where(Agent.user_id == user_id)
37
- .order_by(Agent.created_at.desc())
38
- )
39
- result = await self.db.execute(stmt)
40
- return list(result.scalars().all())
41
-
42
- async def get_agent(self, user_id: str, agent_id: str) -> Agent | None:
43
- stmt = select(Agent).where(Agent.id == agent_id, Agent.user_id == user_id)
44
- result = await self.db.execute(stmt)
45
- return result.scalar_one_or_none()
46
-
47
- async def update_agent(self, user_id: str, agent_id: str, req: UpdateAgentRequest) -> Agent | None:
48
- agent = await self.get_agent(user_id, agent_id)
49
- if not agent:
50
- return None
51
- if req.name is not None:
52
- agent.name = req.name
53
- if req.description is not None:
54
- agent.description = req.description
55
- if req.system_prompt is not None:
56
- agent.system_prompt = req.system_prompt
57
- if req.model_preference is not None:
58
- agent.model_preference = req.model_preference
59
- if req.tools is not None:
60
- agent.tools_config = json.dumps(req.tools)
61
- agent.updated_at = datetime.now(UTC)
62
- await self.db.commit()
63
- await self.db.refresh(agent)
64
- return agent
65
-
66
- async def delete_agent(self, user_id: str, agent_id: str) -> bool:
67
- agent = await self.get_agent(user_id, agent_id)
68
- if not agent:
69
- return False
70
- await self.db.delete(agent)
71
- await self.db.commit()
72
- logger.info("Agent deleted: id=%s user=%s", agent_id, user_id)
73
- return True
74
-
75
- async def heartbeat(self, agent_id: str, user_id: str, status: str) -> Agent | None:
76
- """Update agent heartbeat. user_id comes from the API key owner."""
77
- agent = await self.get_agent(user_id, agent_id)
78
- if not agent:
79
- return None
80
- agent.status = status
81
- agent.last_heartbeat_at = datetime.now(UTC)
82
- agent.updated_at = datetime.now(UTC)
83
- await self.db.commit()
84
- await self.db.refresh(agent)
85
- return agent
86
-
87
- async def mark_stale_agents_offline(self, threshold_seconds: int) -> int:
88
- """Mark agents as offline if no heartbeat within threshold. Returns count."""
89
- cutoff = datetime.now(UTC)
90
- from datetime import timedelta
91
- cutoff = cutoff - timedelta(seconds=threshold_seconds)
92
- stmt = (
93
- select(Agent)
94
- .where(Agent.status != "offline")
95
- .where(
96
- (Agent.last_heartbeat_at < cutoff) | (Agent.last_heartbeat_at.is_(None))
97
- )
98
- )
99
- result = await self.db.execute(stmt)
100
- agents = list(result.scalars().all())
101
- for agent in agents:
102
- agent.status = "offline"
103
- agent.updated_at = datetime.now(UTC)
104
- if agents:
105
- await self.db.commit()
106
- logger.info("Marked %d agents as offline", len(agents))
107
- return len(agents)
23
+ __all__ = ["AgentService", "COMMAND_SIGNING_KEY"]
@@ -5,8 +5,10 @@ from datetime import datetime, timedelta, UTC
5
5
 
6
6
  from sqlalchemy import select
7
7
  from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy.orm import selectinload
8
9
 
9
10
  from backend.models.api_key import ApiKey
11
+ from backend.models.agent import Agent
10
12
 
11
13
  logger = logging.getLogger(__name__)
12
14
 
@@ -45,14 +47,32 @@ class ApiKeyService:
45
47
  logger.info("API key created: user=%s name=%s prefix=%s", user_id, name, prefix)
46
48
  return api_key, raw_key
47
49
 
48
- async def list_keys(self, user_id: str) -> list[ApiKey]:
50
+ async def list_keys(self, user_id: str) -> list[dict]:
51
+ """List keys with agent names for agent-scoped keys."""
49
52
  stmt = (
50
- select(ApiKey)
53
+ select(ApiKey, Agent.name.label("agent_name"))
54
+ .outerjoin(Agent, ApiKey.agent_id == Agent.id)
51
55
  .where(ApiKey.user_id == user_id)
52
56
  .order_by(ApiKey.created_at.desc())
53
57
  )
54
58
  result = await self.db.execute(stmt)
55
- return list(result.scalars().all())
59
+ keys = []
60
+ for row in result.all():
61
+ key = row[0]
62
+ agent_name = row[1] if len(row) > 1 else None
63
+ keys.append({
64
+ "id": key.id,
65
+ "name": key.name,
66
+ "key_prefix": key.key_prefix,
67
+ "is_active": key.is_active,
68
+ "last_used_at": key.last_used_at,
69
+ "created_at": key.created_at,
70
+ "expires_at": key.expires_at,
71
+ "key_type": key.key_type,
72
+ "agent_id": key.agent_id,
73
+ "agent_name": agent_name,
74
+ })
75
+ return keys
56
76
 
57
77
  async def revoke_key(self, user_id: str, key_id: str) -> bool:
58
78
  stmt = select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id)