devops-copilot 0.1.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.
- app/__init__.py +1 -0
- app/cli/__init__.py +1 -0
- app/cli/config.py +41 -0
- app/cli/main.py +251 -0
- app/core/config.py +19 -0
- app/core/database/__init__.py +24 -0
- app/core/security.py +18 -0
- app/main.py +31 -0
- app/modules/__init__.py +1 -0
- app/modules/auth/__init__.py +9 -0
- app/modules/auth/router.py +101 -0
- app/modules/auth/schema.py +34 -0
- app/modules/auth/service.py +91 -0
- app/modules/chat/__init__.py +1 -0
- app/modules/chat/agent.py +443 -0
- app/modules/chat/models.py +48 -0
- app/modules/chat/router.py +362 -0
- app/modules/chat/schema.py +39 -0
- app/modules/guardrails/__init__.py +1 -0
- app/modules/guardrails/schema.py +22 -0
- app/modules/guardrails/service.py +121 -0
- app/modules/knowledge/__init__.py +1 -0
- app/modules/knowledge/service.py +123 -0
- app/modules/servers/__init__.py +1 -0
- app/modules/servers/models.py +30 -0
- app/modules/servers/router.py +67 -0
- app/modules/servers/schema.py +45 -0
- app/modules/servers/service.py +164 -0
- devops_copilot-0.1.0.dist-info/METADATA +81 -0
- devops_copilot-0.1.0.dist-info/RECORD +33 -0
- devops_copilot-0.1.0.dist-info/WHEEL +5 -0
- devops_copilot-0.1.0.dist-info/entry_points.txt +2 -0
- devops_copilot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import uuid
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import asyncssh
|
|
6
|
+
from typing import List, Tuple
|
|
7
|
+
from fastapi import HTTPException, status
|
|
8
|
+
from langchain_openai import ChatOpenAI
|
|
9
|
+
from langchain.agents import create_agent
|
|
10
|
+
from langchain_core.messages import HumanMessage, AIMessage
|
|
11
|
+
from langchain_core.tools import tool
|
|
12
|
+
from langchain_core.callbacks import AsyncCallbackHandler
|
|
13
|
+
from sqlmodel import select
|
|
14
|
+
|
|
15
|
+
from app.core.config import settings
|
|
16
|
+
from app.core.database import async_session_maker
|
|
17
|
+
from app.core.security import decrypt_data
|
|
18
|
+
from app.modules.servers.models import Server
|
|
19
|
+
from app.modules.servers.service import servers_service
|
|
20
|
+
from app.modules.chat.models import AgentAction
|
|
21
|
+
|
|
22
|
+
# Context variables to track active execution details across async threads
|
|
23
|
+
active_websocket = contextvars.ContextVar("active_websocket")
|
|
24
|
+
active_session_id = contextvars.ContextVar("active_session_id")
|
|
25
|
+
active_owner_id = contextvars.ContextVar("active_owner_id")
|
|
26
|
+
|
|
27
|
+
class StreamingCallbackHandler(AsyncCallbackHandler):
|
|
28
|
+
"""Callback handler to stream LLM generation tokens directly to the active WebSocket."""
|
|
29
|
+
def __init__(self, websocket):
|
|
30
|
+
self.websocket = websocket
|
|
31
|
+
|
|
32
|
+
async def on_llm_new_token(self, token: str, **kwargs) -> None:
|
|
33
|
+
try:
|
|
34
|
+
await self.websocket.send_json({
|
|
35
|
+
"type": "token",
|
|
36
|
+
"data": token
|
|
37
|
+
})
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def get_llm(callbacks=None) -> ChatOpenAI:
|
|
42
|
+
"""Initialize OpenRouter LLM client with streaming support."""
|
|
43
|
+
if not settings.OPENROUTER_API_KEY:
|
|
44
|
+
raise ValueError("OPENROUTER_API_KEY is not configured in environment")
|
|
45
|
+
|
|
46
|
+
return ChatOpenAI(
|
|
47
|
+
openai_api_key=settings.OPENROUTER_API_KEY,
|
|
48
|
+
openai_api_base="https://openrouter.ai/api/v1",
|
|
49
|
+
model=settings.OPENROUTER_MODEL,
|
|
50
|
+
temperature=0.0, # Zero temperature for deterministic system operations
|
|
51
|
+
streaming=True,
|
|
52
|
+
callbacks=callbacks or [],
|
|
53
|
+
default_headers={
|
|
54
|
+
"HTTP-Referer": "https://github.com/irzix/devops-copilot",
|
|
55
|
+
"X-Title": "DevOps Copilot Agent"
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def is_write_command(command: str) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Heuristic check to determine if a bash command is state-modifying.
|
|
62
|
+
Read-only commands are allowed to run instantly; others require human verification.
|
|
63
|
+
"""
|
|
64
|
+
cmd = command.strip().lower()
|
|
65
|
+
|
|
66
|
+
# List of safe read-only commands
|
|
67
|
+
read_only_prefixes = (
|
|
68
|
+
"df", "free", "uptime", "uname", "whoami", "hostname",
|
|
69
|
+
"ps", "top", "htop", "cat", "ls", "grep", "tail", "head",
|
|
70
|
+
"stat", "systemctl status", "service status"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Split by common shell execution delimiters to check pipelines
|
|
74
|
+
sub_commands = re.split(r';|&&|\|\||\|', cmd)
|
|
75
|
+
|
|
76
|
+
for sub in sub_commands:
|
|
77
|
+
sub = sub.strip()
|
|
78
|
+
if not sub:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
is_read = False
|
|
82
|
+
for prefix in read_only_prefixes:
|
|
83
|
+
if sub.startswith(prefix):
|
|
84
|
+
# If command writes outputs via redirection, treat it as a write command
|
|
85
|
+
if ">" not in sub:
|
|
86
|
+
is_read = True
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
if not is_read:
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
@tool
|
|
95
|
+
async def list_available_servers() -> str:
|
|
96
|
+
"""
|
|
97
|
+
Retrieve a list of all target servers currently registered in the database.
|
|
98
|
+
Returns their IDs, names, and IP addresses. Use this tool to discover the server_id before running commands.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
owner_id = active_owner_id.get()
|
|
102
|
+
async with async_session_maker() as session:
|
|
103
|
+
statement = select(Server).where(Server.owner_id == owner_id)
|
|
104
|
+
result = await session.exec(statement)
|
|
105
|
+
servers = result.all()
|
|
106
|
+
|
|
107
|
+
if not servers:
|
|
108
|
+
return "No target servers registered yet. Tell the user to register a server first."
|
|
109
|
+
|
|
110
|
+
output = "Available target servers:\n"
|
|
111
|
+
for s in servers:
|
|
112
|
+
output += f"- ID: {s.id} | Name: {s.name} | Host: {s.ip_address}:{s.ssh_port} | User: {s.ssh_username} | Auth: {s.ssh_auth_type}\n"
|
|
113
|
+
return output
|
|
114
|
+
except Exception as e:
|
|
115
|
+
return f"Error retrieving server list: {str(e)}"
|
|
116
|
+
|
|
117
|
+
@tool
|
|
118
|
+
async def execute_ssh_command(server_id: int | str, command: str) -> str:
|
|
119
|
+
"""
|
|
120
|
+
Execute a terminal command asynchronously on a target server via SSH.
|
|
121
|
+
First runs semantic security checks and determines if human-in-the-loop approval is required.
|
|
122
|
+
Streams execution logs line-by-line in real-time to the active terminal session.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
# 1. Run semantic safety guardrail
|
|
126
|
+
from app.modules.guardrails.service import is_command_safe
|
|
127
|
+
is_safe, warning, similarity = is_command_safe(command)
|
|
128
|
+
if not is_safe:
|
|
129
|
+
return f"Execution Blocked by Guardrail: {warning} (similarity index: {similarity:.2f})"
|
|
130
|
+
|
|
131
|
+
# 2. Enforce human verification for state-modifying actions
|
|
132
|
+
if is_write_command(command):
|
|
133
|
+
action_id = f"act_{uuid.uuid4().hex[:12]}"
|
|
134
|
+
sess_id = active_session_id.get()
|
|
135
|
+
owner_id = active_owner_id.get()
|
|
136
|
+
|
|
137
|
+
async with async_session_maker() as session:
|
|
138
|
+
# Check server ownership
|
|
139
|
+
try:
|
|
140
|
+
# Check if server_id is an integer or digit string
|
|
141
|
+
is_id = False
|
|
142
|
+
if isinstance(server_id, int):
|
|
143
|
+
is_id = True
|
|
144
|
+
elif isinstance(server_id, str) and server_id.isdigit():
|
|
145
|
+
is_id = True
|
|
146
|
+
server_id = int(server_id)
|
|
147
|
+
|
|
148
|
+
if is_id:
|
|
149
|
+
server = await servers_service.get_server_by_id(session, server_id, owner_id)
|
|
150
|
+
else:
|
|
151
|
+
server = await servers_service.get_server_by_name(session, str(server_id), owner_id)
|
|
152
|
+
except Exception:
|
|
153
|
+
return f"Error: Server '{server_id}' not found or permission denied."
|
|
154
|
+
|
|
155
|
+
# Record the pending action in database
|
|
156
|
+
action = AgentAction(
|
|
157
|
+
id=action_id,
|
|
158
|
+
session_id=sess_id,
|
|
159
|
+
server_id=server.id,
|
|
160
|
+
command=command,
|
|
161
|
+
status="pending"
|
|
162
|
+
)
|
|
163
|
+
session.add(action)
|
|
164
|
+
await session.commit()
|
|
165
|
+
|
|
166
|
+
# Emit approval notification to WebSocket client
|
|
167
|
+
ws = active_websocket.get()
|
|
168
|
+
await ws.send_json({
|
|
169
|
+
"type": "approval_required",
|
|
170
|
+
"action_id": action_id,
|
|
171
|
+
"server_name": server.name,
|
|
172
|
+
"command": command
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
# Return special status string to halt agent execution loop
|
|
176
|
+
return f"PAUSED: REQUIRES_APPROVAL: {action_id}"
|
|
177
|
+
|
|
178
|
+
# 3. Safe read-only execution: connect and stream output line-by-line
|
|
179
|
+
owner_id = active_owner_id.get()
|
|
180
|
+
async with async_session_maker() as session:
|
|
181
|
+
try:
|
|
182
|
+
# Check if server_id is an integer or digit string
|
|
183
|
+
is_id = False
|
|
184
|
+
if isinstance(server_id, int):
|
|
185
|
+
is_id = True
|
|
186
|
+
elif isinstance(server_id, str) and server_id.isdigit():
|
|
187
|
+
is_id = True
|
|
188
|
+
server_id = int(server_id)
|
|
189
|
+
|
|
190
|
+
if is_id:
|
|
191
|
+
server = await servers_service.get_server_by_id(session, server_id, owner_id)
|
|
192
|
+
else:
|
|
193
|
+
server = await servers_service.get_server_by_name(session, str(server_id), owner_id)
|
|
194
|
+
except Exception:
|
|
195
|
+
return f"Error: Server '{server_id}' not found."
|
|
196
|
+
|
|
197
|
+
credential = decrypt_data(server.encrypted_credential)
|
|
198
|
+
conn_args = {
|
|
199
|
+
"host": server.ip_address,
|
|
200
|
+
"port": server.ssh_port,
|
|
201
|
+
"username": server.ssh_username,
|
|
202
|
+
"known_hosts": None,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if server.ssh_auth_type == "password":
|
|
206
|
+
conn_args["password"] = credential
|
|
207
|
+
else:
|
|
208
|
+
try:
|
|
209
|
+
conn_args["client_keys"] = [asyncssh.import_private_key(credential)]
|
|
210
|
+
except Exception as e:
|
|
211
|
+
return f"Error: Invalid SSH private key format: {str(e)}"
|
|
212
|
+
|
|
213
|
+
ws = active_websocket.get()
|
|
214
|
+
await ws.send_json({
|
|
215
|
+
"type": "stdout",
|
|
216
|
+
"data": f"\n[Executing on {server.name}]: {command}\n"
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
stdout_accumulated = []
|
|
220
|
+
stderr_accumulated = []
|
|
221
|
+
|
|
222
|
+
async def run_with_timeout():
|
|
223
|
+
async with asyncssh.connect(**conn_args) as conn:
|
|
224
|
+
async with conn.create_process(command) as process:
|
|
225
|
+
# Stream stdout line-by-line
|
|
226
|
+
async for line in process.stdout:
|
|
227
|
+
if isinstance(line, bytes):
|
|
228
|
+
line = line.decode("utf-8", errors="replace")
|
|
229
|
+
stdout_accumulated.append(line)
|
|
230
|
+
await ws.send_json({"type": "stdout", "data": line})
|
|
231
|
+
|
|
232
|
+
# Stream stderr line-by-line
|
|
233
|
+
async for line in process.stderr:
|
|
234
|
+
if isinstance(line, bytes):
|
|
235
|
+
line = line.decode("utf-8", errors="replace")
|
|
236
|
+
stderr_accumulated.append(line)
|
|
237
|
+
await ws.send_json({"type": "stderr", "data": line})
|
|
238
|
+
|
|
239
|
+
exit_status = await process.wait()
|
|
240
|
+
exit_code = exit_status.exit_status if exit_status.exit_status is not None else 0
|
|
241
|
+
|
|
242
|
+
await ws.send_json({
|
|
243
|
+
"type": "stdout",
|
|
244
|
+
"data": f"[Process finished with code {exit_code}]\n"
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
stdout_str = "".join(stdout_accumulated)
|
|
248
|
+
stderr_str = "".join(stderr_accumulated)
|
|
249
|
+
|
|
250
|
+
# Auto-index execution output into RAG knowledge base
|
|
251
|
+
try:
|
|
252
|
+
from app.modules.knowledge.service import index_command_output
|
|
253
|
+
index_command_output(server.name, command, stdout_str, stderr_str, exit_code)
|
|
254
|
+
except Exception:
|
|
255
|
+
pass # Non-critical: don't break execution if indexing fails
|
|
256
|
+
|
|
257
|
+
return f"Exit Code: {exit_code}\nStdout:\n{stdout_str}\nStderr:\n{stderr_str}"
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
return await asyncio.wait_for(run_with_timeout(), timeout=30.0)
|
|
261
|
+
except asyncio.TimeoutError:
|
|
262
|
+
return f"Error: Command execution timed out after 30 seconds on {server.name}."
|
|
263
|
+
except Exception as e:
|
|
264
|
+
return f"Tool Execution Error: {str(e)}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@tool
|
|
268
|
+
async def search_knowledge(query: str, sources: list[str] | None = None) -> str:
|
|
269
|
+
"""
|
|
270
|
+
Search the DevOps knowledge base for previously collected information.
|
|
271
|
+
Sources can include: 'command_history', 'server_logs', 'server_configs'.
|
|
272
|
+
If sources is None, all collections are searched.
|
|
273
|
+
Use this tool to recall past command outputs, server logs, or config file contents.
|
|
274
|
+
"""
|
|
275
|
+
try:
|
|
276
|
+
from app.modules.knowledge.service import search
|
|
277
|
+
return search(query, collection_names=sources, n_results=3)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
return f"Knowledge search error: {str(e)}"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@tool
|
|
283
|
+
async def fetch_server_logs(server_id: int | str, log_source: str = "journalctl -n 100 --no-pager") -> str:
|
|
284
|
+
"""
|
|
285
|
+
Fetch recent logs from a target server via SSH, stream them to the user, and auto-index them.
|
|
286
|
+
Default log source is journalctl. Can also use: 'tail -n 100 /var/log/syslog', etc.
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
owner_id = active_owner_id.get()
|
|
290
|
+
async with async_session_maker() as session:
|
|
291
|
+
try:
|
|
292
|
+
is_id = isinstance(server_id, int) or (isinstance(server_id, str) and server_id.isdigit())
|
|
293
|
+
if is_id:
|
|
294
|
+
server = await servers_service.get_server_by_id(session, int(server_id) if isinstance(server_id, str) else server_id, owner_id)
|
|
295
|
+
else:
|
|
296
|
+
server = await servers_service.get_server_by_name(session, str(server_id), owner_id)
|
|
297
|
+
except Exception:
|
|
298
|
+
return f"Error: Server '{server_id}' not found."
|
|
299
|
+
|
|
300
|
+
credential = decrypt_data(server.encrypted_credential)
|
|
301
|
+
conn_args = {
|
|
302
|
+
"host": server.ip_address,
|
|
303
|
+
"port": server.ssh_port,
|
|
304
|
+
"username": server.ssh_username,
|
|
305
|
+
"known_hosts": None,
|
|
306
|
+
}
|
|
307
|
+
if server.ssh_auth_type == "password":
|
|
308
|
+
conn_args["password"] = credential
|
|
309
|
+
else:
|
|
310
|
+
conn_args["client_keys"] = [asyncssh.import_private_key(credential)]
|
|
311
|
+
|
|
312
|
+
ws = active_websocket.get()
|
|
313
|
+
await ws.send_json({"type": "stdout", "data": f"\n[Fetching logs from {server.name}]: {log_source}\n"})
|
|
314
|
+
|
|
315
|
+
async def run_logs_with_timeout():
|
|
316
|
+
async with asyncssh.connect(**conn_args) as conn:
|
|
317
|
+
result = await conn.run(log_source, check=False)
|
|
318
|
+
stdout = result.stdout or ""
|
|
319
|
+
if isinstance(stdout, bytes):
|
|
320
|
+
stdout = stdout.decode("utf-8", errors="replace")
|
|
321
|
+
await ws.send_json({"type": "stdout", "data": stdout})
|
|
322
|
+
return stdout
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
stdout = await asyncio.wait_for(run_logs_with_timeout(), timeout=30.0)
|
|
326
|
+
except asyncio.TimeoutError:
|
|
327
|
+
return f"Error: Fetching logs timed out after 30 seconds on {server.name}."
|
|
328
|
+
|
|
329
|
+
# Auto-index fetched logs
|
|
330
|
+
try:
|
|
331
|
+
from app.modules.knowledge.service import index_log
|
|
332
|
+
index_log(server.name, log_source, stdout)
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
return f"Fetched {len(stdout)} chars of logs from {server.name}.\n{stdout[:2000]}"
|
|
337
|
+
except Exception as e:
|
|
338
|
+
return f"Error fetching logs: {str(e)}"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@tool
|
|
342
|
+
async def fetch_server_config(server_id: int | str, file_path: str) -> str:
|
|
343
|
+
"""
|
|
344
|
+
Read a config file from a target server via SSH (e.g. /etc/nginx/nginx.conf),
|
|
345
|
+
stream it to the user, and auto-index it for future reference.
|
|
346
|
+
"""
|
|
347
|
+
try:
|
|
348
|
+
owner_id = active_owner_id.get()
|
|
349
|
+
async with async_session_maker() as session:
|
|
350
|
+
try:
|
|
351
|
+
is_id = isinstance(server_id, int) or (isinstance(server_id, str) and server_id.isdigit())
|
|
352
|
+
if is_id:
|
|
353
|
+
server = await servers_service.get_server_by_id(session, int(server_id) if isinstance(server_id, str) else server_id, owner_id)
|
|
354
|
+
else:
|
|
355
|
+
server = await servers_service.get_server_by_name(session, str(server_id), owner_id)
|
|
356
|
+
except Exception:
|
|
357
|
+
return f"Error: Server '{server_id}' not found."
|
|
358
|
+
|
|
359
|
+
credential = decrypt_data(server.encrypted_credential)
|
|
360
|
+
conn_args = {
|
|
361
|
+
"host": server.ip_address,
|
|
362
|
+
"port": server.ssh_port,
|
|
363
|
+
"username": server.ssh_username,
|
|
364
|
+
"known_hosts": None,
|
|
365
|
+
}
|
|
366
|
+
if server.ssh_auth_type == "password":
|
|
367
|
+
conn_args["password"] = credential
|
|
368
|
+
else:
|
|
369
|
+
conn_args["client_keys"] = [asyncssh.import_private_key(credential)]
|
|
370
|
+
|
|
371
|
+
ws = active_websocket.get()
|
|
372
|
+
await ws.send_json({"type": "stdout", "data": f"\n[Reading {file_path} from {server.name}]\n"})
|
|
373
|
+
|
|
374
|
+
async def run_config_with_timeout():
|
|
375
|
+
async with asyncssh.connect(**conn_args) as conn:
|
|
376
|
+
result = await conn.run(f"cat {file_path}", check=False)
|
|
377
|
+
stdout = result.stdout or ""
|
|
378
|
+
if isinstance(stdout, bytes):
|
|
379
|
+
stdout = stdout.decode("utf-8", errors="replace")
|
|
380
|
+
stderr = result.stderr or ""
|
|
381
|
+
if isinstance(stderr, bytes):
|
|
382
|
+
stderr = stderr.decode("utf-8", errors="replace")
|
|
383
|
+
|
|
384
|
+
if result.exit_status != 0:
|
|
385
|
+
return f"Error reading {file_path}: {stderr}"
|
|
386
|
+
|
|
387
|
+
await ws.send_json({"type": "stdout", "data": stdout})
|
|
388
|
+
return stdout
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
res = await asyncio.wait_for(run_config_with_timeout(), timeout=30.0)
|
|
392
|
+
if res.startswith("Error reading"):
|
|
393
|
+
return res
|
|
394
|
+
stdout = res
|
|
395
|
+
except asyncio.TimeoutError:
|
|
396
|
+
return f"Error: Reading config timed out after 30 seconds on {server.name}."
|
|
397
|
+
|
|
398
|
+
# Auto-index the config file
|
|
399
|
+
try:
|
|
400
|
+
from app.modules.knowledge.service import index_config
|
|
401
|
+
index_config(server.name, file_path, stdout)
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
return f"Config file {file_path} from {server.name}:\n{stdout[:3000]}"
|
|
406
|
+
except Exception as e:
|
|
407
|
+
return f"Error fetching config: {str(e)}"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# Define list of tools available to the Agent
|
|
411
|
+
tools = [
|
|
412
|
+
list_available_servers,
|
|
413
|
+
execute_ssh_command,
|
|
414
|
+
search_knowledge,
|
|
415
|
+
fetch_server_logs,
|
|
416
|
+
fetch_server_config,
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
# Setup agent prompt template
|
|
420
|
+
system_prompt = """You are a highly capable DevOps AI Assistant managing servers via SSH.
|
|
421
|
+
You have tools to list servers, execute commands, fetch logs/configs, and search past knowledge.
|
|
422
|
+
|
|
423
|
+
IMPORTANT INSTRUCTIONS:
|
|
424
|
+
1. Always call `list_available_servers` first if you do not know the server_id.
|
|
425
|
+
2. Use `search_knowledge` to recall past command outputs, logs, or configs before re-running commands.
|
|
426
|
+
3. Use `fetch_server_logs` to pull and index server logs (journalctl, syslog, etc.).
|
|
427
|
+
4. Use `fetch_server_config` to read and index config files (nginx, systemd, etc.).
|
|
428
|
+
5. Any command you execute will be checked by a semantic guardrail.
|
|
429
|
+
6. If a tool returns 'PAUSED: REQUIRES_APPROVAL: <action_id>', you MUST:
|
|
430
|
+
- STOP all execution immediately. Do not attempt to call any other tools.
|
|
431
|
+
- Reply to the user explaining that the command requires their approval.
|
|
432
|
+
- Explicitly mention the Action ID: '<action_id>'.
|
|
433
|
+
7. Be concise and report the final outputs clearly.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def create_agent_executor(callbacks=None):
|
|
437
|
+
"""Initialize agent compiled StateGraph using modern LangChain create_agent."""
|
|
438
|
+
llm = get_llm(callbacks=callbacks)
|
|
439
|
+
return create_agent(
|
|
440
|
+
model=llm,
|
|
441
|
+
tools=tools,
|
|
442
|
+
system_prompt=system_prompt
|
|
443
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from sqlmodel import SQLModel, Field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
class ChatSession(SQLModel, table=True):
|
|
6
|
+
"""
|
|
7
|
+
Database table storing chat sessions/threads created by administrators.
|
|
8
|
+
"""
|
|
9
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
10
|
+
title: str = Field(index=True, nullable=False)
|
|
11
|
+
user_id: int = Field(foreign_key="user.id", nullable=False)
|
|
12
|
+
created_at: datetime = Field(
|
|
13
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
14
|
+
nullable=False
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class ChatMessage(SQLModel, table=True):
|
|
18
|
+
"""
|
|
19
|
+
Database table storing the historical messages for chat sessions.
|
|
20
|
+
"""
|
|
21
|
+
id: Optional[int] = Field(default=None, primary_key=True)
|
|
22
|
+
session_id: int = Field(foreign_key="chatsession.id", nullable=False)
|
|
23
|
+
|
|
24
|
+
# Either "user" or "ai"
|
|
25
|
+
sender: str = Field(index=True, nullable=False)
|
|
26
|
+
content: str = Field(nullable=False)
|
|
27
|
+
created_at: datetime = Field(
|
|
28
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
29
|
+
nullable=False
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
class AgentAction(SQLModel, table=True):
|
|
33
|
+
"""
|
|
34
|
+
Database table storing target server execution requests generated by the AI
|
|
35
|
+
which require human-in-the-loop validation/approval before being executed.
|
|
36
|
+
"""
|
|
37
|
+
# UUID string to make the action approval identifier secure and unguessable
|
|
38
|
+
id: str = Field(primary_key=True)
|
|
39
|
+
session_id: int = Field(foreign_key="chatsession.id", nullable=False)
|
|
40
|
+
server_id: int = Field(foreign_key="server.id", nullable=False)
|
|
41
|
+
command: str = Field(nullable=False)
|
|
42
|
+
|
|
43
|
+
# Status can be: "pending", "approved", or "rejected"
|
|
44
|
+
status: str = Field(default="pending", index=True, nullable=False)
|
|
45
|
+
created_at: datetime = Field(
|
|
46
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
47
|
+
nullable=False
|
|
48
|
+
)
|