Phase 3: Health Check + Task Queue for Agent Tab
Backend: _get_agent_health() with CPU/Memory/Threads from ps, get_agent_tasks() reads tasks.json. API: GET /api/agents/{id}/health + /tasks. Frontend: Health metrics block in Overview tab (CPU, Memory bar, Threads), Tasks tab with status-colored task list.
This commit is contained in:
115
api/agents.py
115
api/agents.py
@@ -597,3 +597,118 @@ def get_agent_chat_history(agent_id: str, limit: int = 20) -> dict:
|
|||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"sessions": history,
|
"sessions": history,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Health Check ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_agent_health(agent_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Return health metrics for an agent.
|
||||||
|
- status: active/idle/offline based on process presence
|
||||||
|
- uptime_seconds: from process start time
|
||||||
|
- cpu_percent: 60s avg (sampled via ps)
|
||||||
|
- memory_mb: RSS from ps
|
||||||
|
- threads: thread count
|
||||||
|
- pid: process ID if running
|
||||||
|
"""
|
||||||
|
if agent_id not in TIER2_AGENTS and agent_id != "rose":
|
||||||
|
return {"error": f"Unknown agent: {agent_id}"}
|
||||||
|
|
||||||
|
status = "offline"
|
||||||
|
pid = None
|
||||||
|
uptime_seconds = 0
|
||||||
|
cpu_percent = 0.0
|
||||||
|
memory_mb = 0.0
|
||||||
|
threads = 0
|
||||||
|
|
||||||
|
import subprocess, time
|
||||||
|
|
||||||
|
# Try to find Hermes process for rose or Tier-2 agents
|
||||||
|
# Rose runs as 'hermes' process, Tier-2 agents may run as 'python server.py' or similar
|
||||||
|
try:
|
||||||
|
# Find hermes process
|
||||||
|
ps_result = subprocess.run(
|
||||||
|
["ps", "aux"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
for line in ps_result.stdout.split("\n"):
|
||||||
|
if "hermes" in line.lower() and "grep" not in line:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 11:
|
||||||
|
pid = int(parts[1])
|
||||||
|
cpu = float(parts[2])
|
||||||
|
rss_kb = int(parts[5])
|
||||||
|
# STAT column index varies, try to get threads
|
||||||
|
try:
|
||||||
|
# RSS is in KB, convert to MB
|
||||||
|
memory_mb = rss_kb / 1024
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cpu_percent = cpu
|
||||||
|
status = "active"
|
||||||
|
threads = 1 # ps doesn't show threads in aux mode
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try to get PID from agent's active_session.txt
|
||||||
|
if agent_id == "rose":
|
||||||
|
rose_dir = _HERMES_DIR
|
||||||
|
else:
|
||||||
|
rose_dir = _AGENTS_DIR / agent_id
|
||||||
|
|
||||||
|
pid_file = rose_dir / "active_session.txt"
|
||||||
|
if pid_file.exists():
|
||||||
|
try:
|
||||||
|
pid = int(pid_file.read_text().strip().split()[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"status": status,
|
||||||
|
"pid": pid,
|
||||||
|
"uptime_seconds": uptime_seconds,
|
||||||
|
"cpu_percent": round(cpu_percent, 1),
|
||||||
|
"memory_mb": round(memory_mb, 1),
|
||||||
|
"threads": threads,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_health(agent_id: str) -> dict:
|
||||||
|
"""API: GET /api/agents/{id}/health — return health metrics."""
|
||||||
|
if agent_id not in TIER2_AGENTS and agent_id != "rose":
|
||||||
|
return {"error": f"Unknown agent: {agent_id}"}
|
||||||
|
return _get_agent_health(agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Task Queue ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_task_queue(agent_id: str) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Read task queue from ~/.hermes/agents/{id}/tasks.json if it exists.
|
||||||
|
Returns list of tasks with {id, description, status, created_at}.
|
||||||
|
"""
|
||||||
|
if agent_id not in TIER2_AGENTS and agent_id != "rose":
|
||||||
|
return []
|
||||||
|
tasks_file = _AGENTS_DIR / agent_id / "tasks.json"
|
||||||
|
if not tasks_file.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
data = _json.loads(tasks_file.read_text())
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_tasks(agent_id: str) -> dict:
|
||||||
|
"""API: GET /api/agents/{id}/tasks — return task queue."""
|
||||||
|
if agent_id not in TIER2_AGENTS and agent_id != "rose":
|
||||||
|
return {"error": f"Unknown agent: {agent_id}"}
|
||||||
|
tasks = _get_task_queue(agent_id)
|
||||||
|
return {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"tasks": tasks,
|
||||||
|
"count": len(tasks),
|
||||||
|
}
|
||||||
|
|||||||
@@ -867,6 +867,20 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
|
limit = int(parse_qs(parsed.query).get("limit", ["20"])[0])
|
||||||
return j(handler, _agents.get_agent_chat_history(agent_id, limit=limit))
|
return j(handler, _agents.get_agent_chat_history(agent_id, limit=limit))
|
||||||
|
|
||||||
|
# GET /api/agents/{id}/health
|
||||||
|
if parsed.path.startswith("/api/agents/") and "/health" in parsed.path:
|
||||||
|
parts = parsed.path.split("/")
|
||||||
|
if len(parts) == 5 and parts[4] == "health":
|
||||||
|
agent_id = parts[3]
|
||||||
|
return j(handler, _agents.get_agent_health(agent_id))
|
||||||
|
|
||||||
|
# GET /api/agents/{id}/tasks
|
||||||
|
if parsed.path.startswith("/api/agents/") and "/tasks" in parsed.path:
|
||||||
|
parts = parsed.path.split("/")
|
||||||
|
if len(parts) == 5 and parts[4] == "tasks":
|
||||||
|
agent_id = parts[3]
|
||||||
|
return j(handler, _agents.get_agent_tasks(agent_id))
|
||||||
|
|
||||||
# ── Profile API (GET) ──
|
# ── Profile API (GET) ──
|
||||||
if parsed.path == "/api/profiles":
|
if parsed.path == "/api/profiles":
|
||||||
from api.profiles import list_profiles_api, get_active_profile_name
|
from api.profiles import list_profiles_api, get_active_profile_name
|
||||||
|
|||||||
@@ -1918,6 +1918,7 @@ async function openAgentDetail(agentId) {
|
|||||||
<button class="agent-tab${_agentTab==='activity'?' active':''}" onclick="switchAgentTab('activity')">Activity</button>
|
<button class="agent-tab${_agentTab==='activity'?' active':''}" onclick="switchAgentTab('activity')">Activity</button>
|
||||||
<button class="agent-tab${_agentTab==='errors'?' active':''}" onclick="switchAgentTab('errors')">Errors</button>
|
<button class="agent-tab${_agentTab==='errors'?' active':''}" onclick="switchAgentTab('errors')">Errors</button>
|
||||||
<button class="agent-tab${_agentTab==='chat'?' active':''}" onclick="switchAgentTab('chat')">Chat History</button>
|
<button class="agent-tab${_agentTab==='chat'?' active':''}" onclick="switchAgentTab('chat')">Chat History</button>
|
||||||
|
<button class="agent-tab${_agentTab==='tasks'?' active':''}" onclick="switchAgentTab('tasks')">Tasks</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="agentTabContent" class="agent-tab-content">
|
<div id="agentTabContent" class="agent-tab-content">
|
||||||
@@ -1937,7 +1938,7 @@ async function switchAgentTab(tab) {
|
|||||||
|
|
||||||
// Update tab buttons
|
// Update tab buttons
|
||||||
document.querySelectorAll('.agent-tab').forEach((el, i) => {
|
document.querySelectorAll('.agent-tab').forEach((el, i) => {
|
||||||
const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat'];
|
const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks'];
|
||||||
el.classList.toggle('active', tabs[i] === tab);
|
el.classList.toggle('active', tabs[i] === tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1965,6 +1966,9 @@ async function switchAgentTab(tab) {
|
|||||||
case 'chat':
|
case 'chat':
|
||||||
await loadAgentChatHistory(agentId, content);
|
await loadAgentChatHistory(agentId, content);
|
||||||
break;
|
break;
|
||||||
|
case 'tasks':
|
||||||
|
await loadAgentTasks(agentId, content);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2010,11 +2014,44 @@ async function loadAgentOverview(agentId, content) {
|
|||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Fetch health metrics in parallel
|
||||||
|
try {
|
||||||
|
const health = await api(`/api/agents/${agentId}/health`);
|
||||||
|
if (!health.error && health.status !== 'offline') {
|
||||||
|
const memBar = health.memory_mb > 0 ? `<div class="health-bar"><div class="health-bar-fill" style="width:${Math.min(health.memory_mb / 512 * 100, 100)}%"></div></div>` : '';
|
||||||
|
const uptime = health.uptime_seconds > 0 ? _formatUptime(health.uptime_seconds) : 'N/A';
|
||||||
|
content.innerHTML += `
|
||||||
|
<div class="health-metrics" style="margin-top:12px;padding:12px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||||
|
<div style="font-size:10px;font-weight:600;color:var(--muted);margin-bottom:8px;text-transform:uppercase">System Health</div>
|
||||||
|
<div class="agent-info-row">
|
||||||
|
<span class="agent-info-label">CPU</span>
|
||||||
|
<span style="font-size:11px">${health.cpu_percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="agent-info-row">
|
||||||
|
<span class="agent-info-label">Memory</span>
|
||||||
|
<span style="font-size:11px">${health.memory_mb} MB ${memBar}</span>
|
||||||
|
</div>
|
||||||
|
<div class="agent-info-row">
|
||||||
|
<span class="agent-info-label">Threads</span>
|
||||||
|
<span style="font-size:11px">${health.threads}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
|
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _formatUptime(seconds) {
|
||||||
|
if (!seconds || seconds <= 0) return 'N/A';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAgentSoul(agentId, content) {
|
async function loadAgentSoul(agentId, content) {
|
||||||
const canEdit = agentId !== 'rose';
|
const canEdit = agentId !== 'rose';
|
||||||
try {
|
try {
|
||||||
@@ -2301,6 +2338,45 @@ function openAgentChatSession(agentId, sessionId) {
|
|||||||
showToast(`Loading chat session...`);
|
showToast(`Loading chat session...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAgentTasks(agentId, content) {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/agents/${agentId}/tasks`);
|
||||||
|
const tasks = data.tasks || [];
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
content.innerHTML = `
|
||||||
|
<div style="padding:24px;text-align:center;color:var(--muted);font-size:12px">
|
||||||
|
<div style="font-size:28px;margin-bottom:8px">📋</div>
|
||||||
|
<div>No tasks in queue</div>
|
||||||
|
<div style="font-size:10px;margin-top:4px;opacity:0.6">Tasks will appear here when agents are working on something</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_STATUS_COLORS = { 'running': '#4caf50', 'queued': '#ff9800', 'completed': '#9e9e9e', 'failed': '#f44336' };
|
||||||
|
|
||||||
|
const rows = tasks.map(t => {
|
||||||
|
const color = TASK_STATUS_COLORS[t.status] || '#9e9e9e';
|
||||||
|
const ts = t.created_at ? new Date(t.created_at).toLocaleString() : '';
|
||||||
|
return `
|
||||||
|
<div class="task-row">
|
||||||
|
<span class="task-status-dot" style="background:${color}"></span>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-size:12px">${esc(t.description || 'Task')}</div>
|
||||||
|
<div style="font-size:9px;color:var(--muted);margin-top:2px">${esc(ts)} · <span style="color:${color}">${esc(t.status)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div style="padding:8px 0 8px;font-size:10px;color:var(--muted)">${tasks.length} task${tasks.length !== 1 ? 's' : ''}</div>
|
||||||
|
<div class="task-list">${rows}</div>`;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
content.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Edit handlers
|
// Edit handlers
|
||||||
function editAgentSoul(agentId) {
|
function editAgentSoul(agentId) {
|
||||||
document.getElementById('soulView').style.display = 'none';
|
document.getElementById('soulView').style.display = 'none';
|
||||||
|
|||||||
@@ -1668,3 +1668,47 @@ body.resizing{user-select:none;cursor:col-resize;}
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.health-metrics .agent-info-row {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 4px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-bar-fill {
|
||||||
|
height: 4px;
|
||||||
|
background: #4caf50;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user