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.
- kairo/backend/api/agents.py +337 -16
- kairo/backend/app.py +84 -4
- kairo/backend/config.py +4 -2
- kairo/backend/models/agent.py +216 -2
- kairo/backend/models/api_key.py +4 -1
- kairo/backend/models/task.py +31 -0
- kairo/backend/models/user_provider_key.py +26 -0
- kairo/backend/schemas/agent.py +249 -2
- kairo/backend/schemas/api_key.py +3 -0
- kairo/backend/services/agent/__init__.py +52 -0
- kairo/backend/services/agent/agent_alerts_evaluation_service.py +224 -0
- kairo/backend/services/agent/agent_alerts_service.py +201 -0
- kairo/backend/services/agent/agent_commands_service.py +142 -0
- kairo/backend/services/agent/agent_crud_service.py +150 -0
- kairo/backend/services/agent/agent_events_service.py +103 -0
- kairo/backend/services/agent/agent_heartbeat_service.py +207 -0
- kairo/backend/services/agent/agent_metrics_rollup_service.py +248 -0
- kairo/backend/services/agent/agent_metrics_service.py +259 -0
- kairo/backend/services/agent/agent_service.py +315 -0
- kairo/backend/services/agent/agent_setup_service.py +180 -0
- kairo/backend/services/agent/constants.py +28 -0
- kairo/backend/services/agent_service.py +18 -102
- kairo/backend/services/api_key_service.py +23 -3
- kairo/backend/services/byok_service.py +204 -0
- kairo/backend/services/chat_service.py +398 -63
- kairo/backend/services/deep_search_service.py +159 -0
- kairo/backend/services/email_service.py +418 -19
- kairo/backend/services/few_shot_service.py +223 -0
- kairo/backend/services/post_processor.py +261 -0
- kairo/backend/services/rag_service.py +150 -0
- kairo/backend/services/task_service.py +119 -0
- kairo/backend/tests/__init__.py +1 -0
- kairo/backend/tests/e2e/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/__init__.py +1 -0
- kairo/backend/tests/e2e/agents/conftest.py +389 -0
- kairo/backend/tests/e2e/agents/test_agent_alerts.py +802 -0
- kairo/backend/tests/e2e/agents/test_agent_commands.py +456 -0
- kairo/backend/tests/e2e/agents/test_agent_crud.py +455 -0
- kairo/backend/tests/e2e/agents/test_agent_events.py +415 -0
- kairo/backend/tests/e2e/agents/test_agent_heartbeat.py +520 -0
- kairo/backend/tests/e2e/agents/test_agent_metrics.py +587 -0
- kairo/backend/tests/e2e/agents/test_agent_setup.py +349 -0
- kairo/migrations/versions/010_agent_dashboard.py +246 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/METADATA +1 -1
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/RECORD +50 -16
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/top_level.txt +1 -0
- kairo_migrations/env.py +92 -0
- kairo_migrations/versions/001_add_agent_dashboard_extensions.py +450 -0
- {kairo_code-0.1.0.dist-info → kairo_code-0.2.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
2
|
-
|
|
3
|
-
from datetime import datetime, UTC
|
|
1
|
+
"""
|
|
2
|
+
Agent service - backward compatibility re-export.
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
from
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
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)
|