Build a process analyst agent in C++ that explains running processes, detects suspicious files, and lets you stop, restart, or quarantine items — all with local LLM inference
Source Code:cpp/examples/process_agent.cpp — single-file, self-contained agent (~2,900 lines including 7 tools, action menu, and decision support).
Platform: Windows (Win32 APIs + PowerShell). Compiles on Linux/macOS for CI but tools require Windows to return real data.
Prerequisite: Lemonade Server running with a model loaded.
Recommended: Run as Administrator for full process visibility and quarantine access.
The Process Analyst is an AI agent that makes sense of what’s running on your PC. On startup it scans every process and service, classifies them by resource use and behavior, and shows you a plain-English summary. You can ask it to explain any task — what it is, what it does, and whether there’s reason to be concerned — or take action directly.Here’s what makes it interesting:
It explains what’s running — every process gets a plain-English description: what it is, what it does, and whether it looks normal
Ask about any task — describe an item from the summary and get a clear explanation without needing technical knowledge
Manage processes and services — stop, restart, or quarantine items; the agent always asks before acting
Background monitoring — toggle a background watcher that alerts you to memory spikes, new suspicious items, and health changes while you keep working
The agent auto-runs a full system scan on startup, then waits for your input. You can ask about any item in plain English — or use the action menu to stop, restart, or quarantine it.
Ninja requires two prerequisites before running these commands:
Ninja installed — winget install Ninja-build.Ninja (then restart your terminal)
MSVC compiler in PATH — Ninja does not locate cl.exe automatically the way the Visual Studio generator does. Open an x64 Native Tools Command Prompt for VS 2022 — this puts cl.exe in PATH automatically. Do not use a plain PowerShell or CMD window.
The agent runs in two phases. On startup it auto-scans the system — no menu shown first. The LLM calls system_snapshot and list_processes, classifies everything into three labeled sections (A: Processes, B: Services, C: Suspicious Items), then presents an action menu.Conversation history is preserved across all actions so the LLM can reference any labeled item without re-scanning. Only Reanalyze clears history, because the system state has genuinely changed.
The agent also includes a dedicated path validator for quarantine operations:
static bool isSafePath(const std::string& path) { if (path.size() < 3) return false; if (!std::isalpha(static_cast<unsigned char>(path[0])) || path[1] != ':' || path[2] != '\\') return false; const std::string dangerous = "\"`;|&{}<>$"; for (char c : path) { if (dangerous.find(c) != std::string::npos) return false; } return true;}
Why a separate path validator? File paths need drive-letter validation and a different dangerous character set than shell arguments. isSafeShellArg() rejects single quotes (valid in Windows paths), while isSafePath() requires the X:\ prefix and allows single quotes but blocks shell metacharacters. Two validators, two threat models.
Rather than relying on PowerShell for everything, the Process Analyst uses native Win32 APIs for its hot path — process enumeration and memory measurement. The core of getTopProcesses() takes a snapshot, groups processes by exe name, then enriches each entry with version info and factual flags:
static gaia::json getTopProcesses(int topN = 20) { HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); // ... enumerate processes, measure memory via K32GetProcessMemoryInfo ... // ... group by exe name, accumulate total memory and instance count ... for (auto& g : sorted) { // ... enrich with version info (company, description) via GetFileVersionInfoW ... // Factual flags for LLM classification gaia::json flags = gaia::json::array(); if (company.empty()) flags.push_back("unknown_company"); if (lowerPath.find("\\Downloads\\") != std::string::npos) flags.push_back("temp_path"); entry["flags"] = flags; } return result;}
Three design choices worth understanding:
Performance — CreateToolhelp32Snapshot + K32GetProcessMemoryInfo returns data in ~20ms for 200+ processes. Get-Process | ConvertTo-Json takes 2-3 seconds for the same data. When the agent needs to scan on every startup, that difference matters.
Process grouping — Instead of listing 45 separate chrome.exe entries, the agent groups by exe name with instance count and total memory. The LLM sees “chrome.exe x45 — 3.2 GB” which is far more actionable than 45 lines of individual PIDs.
Factual flags — unknown_company, unknown_description, temp_path are computed as structured metadata from Win32 GetFileVersionInfoW and path analysis. The LLM uses these as input to classification, not as final verdicts.
3. Tool Registration: Teaching the Agent What It Can Do
Tools are registered with a name, description, callback, and typed parameter list. The framework automatically includes registered tools in the system prompt sent to the LLM.Here’s system_snapshot, which demonstrates the hybrid Win32 + PowerShell approach:
toolRegistry().registerTool( "system_snapshot", "System overview: CPU load, memory, disk, uptime, top processes by memory " "(with company, description, path, and factual flags), problematic services, " "and network connection count. Use first during analysis.", [](const gaia::json& /*args*/) -> gaia::json { gaia::json result; result["tool"] = "system_snapshot"; result["disk"] = getDiskUsageInfo(); // Win32: GetDiskFreeSpaceExW result["memory"] = getMemoryInfo(); // Win32: GlobalMemoryStatusEx result["top_processes"] = getTopProcesses(30); // Win32: CreateToolhelp32Snapshot // PowerShell: CPU load, uptime, services, connections std::string psCmd = "$o=@{}; $c=Get-CimInstance Win32_Processor; ..."; // ... merge PowerShell results into result ... result["command"] = "Win32 API: memory, disk, processes + PowerShell: CPU, services, connections"; return result; }, {} // no parameters);
And kill_process, which shows parameter validation and the command + output contract:
toolRegistry().registerTool( "kill_process", "Terminate a running process by name. ONLY use after explicit user confirmation.", [](const gaia::json& args) -> gaia::json { std::string name = args.value("name", ""); if (name.empty()) return {{"error", "Process name is required"}}; // Validate: only alphanumeric + . - _ // ... code redacted for simplicity ... KillResult kr = killProcessesByName(name); return { {"tool", "kill_process"}, {"terminated", kr.terminated}, {"command", "Win32 API: TerminateProcess(" + name + ")"}, {"output", "Found N instances, terminated N, freed X MB"} }; }, {{"name", gaia::ToolParamType::STRING, true, "Process name (e.g., 'chrome.exe')"}});
Why command + output keys? Every tool returns both a command string and an output string. The ProcessConsole uses these for its TUI display — the command appears in the tool header, and output appears in the preview box. This consistent contract means the output handler doesn’t need to understand tool-specific JSON structures.
The system prompt teaches the LLM how to classify processes into three labeled sections and how to respond to user actions. Here are the core classification criteria:
### A. Processes (resource consumers)Select the most significant resource consumers. Consider:- Memory usage relative to total system RAM- Instance count (many instances = noteworthy)- Whether the resource usage is expected for that application typeTag each: [NORMAL] expected, [HIGH] unexpectedly high, [SUSPICIOUS]### B. Services (problematic)Report services with Status not "OK" or Memory > 200 MB.If none: "All services running normally"### C. Suspicious ItemsThe flags array provides factual signals — use as INPUT, not verdicts:- unknown_company + unknown_description = STRONG signal- temp_path = STRONG signal for non-installers- EXCEPTIONS: svchost.exe, csrss.exe, smss.exe may show empty company — NORMAL
The system prompt also defines the action behavior protocol, including the multi-step quarantine confirmation:
## ACTION BEHAVIORUsers reference items by group letter + number (e.g., "Explain A3", "Stop B1").- **Quarantine**: Before calling quarantine_item, you MUST: 1. Call explain_item to get full details 2. Present confirmation in Key: Value format 3. Ask: Kill [name] and move it to quarantine? (yes / no) 4. Wait for response. If NO, abort. If YES, call quarantine_item.
Why A/B/C labels? They create a shared vocabulary between the LLM and the user. “Explain A3” unambiguously maps to the third process in the resource consumers section — no re-scanning required.Why explicit quarantine protocol? Quarantine is irreversible — it kills the process AND moves the executable file. The multi-step protocol (explain, confirm, wait for yes/no) prevents accidental data loss from an LLM hallucination.
The ProcessConsole overrides CleanConsole::printFinalAnswer() to render the LLM’s structured A/B/C output with visual hierarchy. It applies six line detection rules in order (first match wins):
Blank line — suppresses consecutive blanks to keep output tight
Section header — “A. Processes”, “B. Services”, “C. Suspicious Items” — rendered as bold white label with a gray description line underneath
Item reference — A4: cpptools-srv.exe — bold cyan tag, bold white name
Key: Value — first colon within 20 chars with alpha key — bold white key, normal value
Numbered item — 1. chrome.exe ... — normal rendering with markdown bold support
Why override printFinalAnswer()? The LLM outputs structured sections that need visual hierarchy. Default CleanConsole treats all text equally — ProcessConsole adds section headers with descriptions, color-coded item tags, and Key: Value formatting that makes the output scannable at a glance.
The monitor is a background thread that periodically re-scans the system and compares consecutive snapshots to detect changes. It runs alongside the interactive agent — you can keep explaining, stopping, or restarting processes while the monitor watches for anomalies.Starting and stopping. Monitor is a toggle on [6]. Press it once to start, again to stop. You can also specify an interval: 6 5 starts monitoring every 5 minutes. Typing monitor or monitor N at the prompt does the same thing. The default interval is 5 minutes.How it works internally. When started, the agent spawns a second ProcessAgent on a dedicated thread. This background agent uses a SilentConsole — it runs the same system_snapshot and list_processes tools but produces no visible output. After each scan it builds a MonitorSnapshot (memory usage, top process list, suspicious items, health status) and diffs it against the previous snapshot. Any meaningful changes become alerts.Alert types and thresholds:
Alert
Trigger
Severity
Memory spike
System memory use jumps >10 percentage points
WARNING (CRITICAL if >90%)
Per-process memory surge
A single process grows by >500 MB
WARNING
New process in top-20
A process appears that was not in the previous top-20
INFO
Process left top-20
A process drops out of the previous top-20
INFO
New suspicious item
LLM flags a new item in section C
CRITICAL
Health status change
Overall health classification changes
WARNING or CRITICAL
Alert delivery. Pending alerts are displayed before the next menu prompt, so you see them naturally between actions. For CRITICAL and NEW_SUSPICIOUS alerts, the agent also sends a Windows notification so you get alerted even if the terminal is in the background.Design choices:
Separate agent instance — the monitor’s ProcessAgent has its own conversation history and LLM connection. This avoids corrupting the interactive agent’s context with monitoring chatter.
SilentConsole — suppresses all tool output and conclusions from the background scan. Only the diff-based alerts reach the user.
Deterministic scans — the monitor agent uses temperature=0 for consistent health classifications across consecutive scans when system state hasn’t changed.
Toggle, not a mode — the interactive menu stays fully functional while the monitor runs. There is no “monitor mode” to enter or exit.
NPU-friendly — background monitoring is an ideal workload for AMD Ryzen AI NPU inference. The monitor runs periodic scans without tying up the CPU or GPU, leaving them free for your other work.
Diagnostic nodes (dark) gather data. Red nodes are destructive actions requiring confirmation. Blue nodes are the Reanalyze and Monitor paths. Orange is the alert display. Green nodes are conclusion displays where the user reads output.
Want to add your own tools? The pattern is straightforward. Here’s an example that lists Windows startup items:
// Add this inside registerTools()toolRegistry().registerTool( "check_startup_items", "List programs that run at Windows startup — registry Run keys and Startup folder.", [](const gaia::json& /*args*/) -> gaia::json { std::string output = runShell( "Get-CimInstance Win32_StartupCommand " "| Select-Object Name, Command, Location " "| ConvertTo-Json -Depth 2" ); return {{"tool", "check_startup_items"}, {"command", "PowerShell: Get-CimInstance Win32_StartupCommand"}, {"output", output}}; }, {});
The framework automatically appends registered tools to the system prompt — the new tool is available to the LLM immediately. Optionally, add an ActionEntry to kActions[] if you want a new numbered menu item (e.g., [7] Startup Items).