Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ DEEPSQL_EMBEDDING_MODEL=text-embedding-3-large
# profile which defaults to false.
EMBEDDING_FAIL_OPEN=false

# ── Hermes agent (Agent tab + AI dashboards) ────────────────────────────────
# Backend → Hermes for dashboards / Slack / CLI. Compose defaults this; only
# override if Hermes listens elsewhere. Requires scripts/self-host/setup-agent.sh
# (also run by install.sh unless DEEPSQL_SKIP_AGENT_SETUP=1).
#AGENT_WEBUI_URL=http://host.docker.internal:8787
#DEEPSQL_SKIP_AGENT_SETUP=0
#DEEPSQL_SMOKE_AGENT=1

# ── The /api/llm/v1 agent gateway ───────────────────────────────────────────
# The DeepSQL CLI agent (`@deepsql/mcp`) points its model at <backend>/api/llm/v1
# and authenticates with a DeepSQL token; the backend forwards those calls
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,5 @@ optd-sidecar/target/
.idea/
.vscode/
*.iml
.local-admin-credentials
.local-mcp-token
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,10 @@ is rejected. Changing width means migrating the column.

The installer generates your JWT secret, the credential-vault encryption key and the vault
DB password, prompts for the first admin account, builds both images, starts the stack, and
verifies pgvector is live.
verifies pgvector is live. Unless you set `DEEPSQL_SKIP_AGENT_SETUP=1`, it also runs
[`scripts/self-host/setup-agent.sh`](scripts/self-host/setup-agent.sh) to install Hermes under
`~/.hermes/`, wire DeepSQL MCP, and start the webui on `0.0.0.0:8787` (required for the
**Agent** tab and AI dashboard generation).

**The first build takes several minutes** — it compiles the Spring Boot backend with Maven
inside the container and bundles the frontend with Vite. It has not hung. Later builds reuse
Expand Down
101 changes: 81 additions & 20 deletions agent/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,115 @@
# Idempotent: safe to re-run. Source of truth is this repo's agent/ dir.
#
# Configures:
# - model: existing Azure OpenAI gpt-5.4 via its OpenAI-compatible v1 endpoint
# - model: from DEEPSQL_CHAT_* (or legacy AZURE_OPENAI_*) via OpenAI-compatible endpoint
# - mcp_servers.deepsql: the repo's DeepSQL MCP server (read-only DBA tools)
# - skills.external_dirs: this repo's agent/skills (source of truth)
# - approvals.mode: smart
# - SOUL.md: the DBA persona
# - disables host-affecting toolsets (terminal/file/code/browser/computer_use)
#
# Secrets are read from the environment (or the repo .env), never committed:
# AZURE_OPENAI_KEY, AZURE_OPENAI_ENDPOINT (endpoint defaults to the repo value)
# DEEPSQL_CHAT_API_KEY, DEEPSQL_CHAT_ENDPOINT, DEEPSQL_CHAT_MODEL
# (legacy fallback: AZURE_OPENAI_KEY, AZURE_OPENAI_ENDPOINT)
#
# Upstream note: HERMES_HOME / hermes-agent / hermes CLI are contracts of the
# Nous Hermes Agent runtime this customization runs on — do not rename those.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
# Reject a nested profile home inherited from a live Hermes process.
if [[ "${HERMES_HOME:-}" == */profiles/* ]]; then
unset HERMES_HOME
fi
HERMES_HOME="${DEEPSQL_HERMES_HOME:-${HERMES_HOME:-$HOME/.hermes}}"
AGENT_DIR="${HERMES_AGENT_DIR:-$HERMES_HOME/hermes-agent}"
VENV_PY="$AGENT_DIR/.venv/bin/python"

# Load repo .env for Azure creds if not already in the environment.
# Prefer the same interpreter order as scripts/self-host/setup-agent.sh
if [[ -x "$AGENT_DIR/venv/bin/python" ]]; then
VENV_PY="$AGENT_DIR/venv/bin/python"
elif [[ -x "$AGENT_DIR/.venv/bin/python" ]]; then
VENV_PY="$AGENT_DIR/.venv/bin/python"
else
echo "Agent venv not found under $AGENT_DIR — run scripts/self-host/setup-agent.sh first." >&2
exit 1
fi

# Load repo .env for LLM creds if not already in the environment.
if [[ -f "$REPO_ROOT/.env" ]]; then set -a; . "$REPO_ROOT/.env"; set +a; fi
: "${AZURE_OPENAI_KEY:?Set AZURE_OPENAI_KEY (or add it to repo .env)}"
AZURE_ENDPOINT_HOST="$(printf '%s' "${AZURE_OPENAI_ENDPOINT:-https://your-resource.cognitiveservices.azure.com/}" | sed -E 's#https?://##; s#/.*##; s#\.cognitiveservices\.azure\.com#.openai.azure.com#')"
BASE_URL="https://${AZURE_ENDPOINT_HOST}/openai/v1"

[[ -x "$VENV_PY" ]] || { echo "Agent venv not found at $VENV_PY — install the agent runtime first."; exit 1; }
# Prefer the same BYO-LLM vars the Spring backend uses. Fall back to legacy
# AZURE_OPENAI_* for older checkouts.
API_KEY="${DEEPSQL_CHAT_API_KEY:-${AZURE_OPENAI_KEY:-}}"
ENDPOINT="${DEEPSQL_CHAT_ENDPOINT:-${AZURE_OPENAI_ENDPOINT:-}}"
MODEL="${DEEPSQL_CHAT_MODEL:-gpt-5.4}"

if [[ -z "$API_KEY" ]]; then
echo "Error: set DEEPSQL_CHAT_API_KEY (or AZURE_OPENAI_KEY) in the environment or $REPO_ROOT/.env" >&2
exit 1
fi
if [[ -z "$ENDPOINT" ]]; then
echo "Error: set DEEPSQL_CHAT_ENDPOINT (or AZURE_OPENAI_ENDPOINT)." >&2
exit 1
fi

# Normalize to an OpenAI-compatible …/openai/v1 or …/v1 base URL.
# Azure Cognitive Services / Azure OpenAI hosts need /openai/v1; plain OpenAI
# and OpenAI-compatible servers already expose /v1.
normalize_base_url() {
local ep="$1"
ep="${ep%/}"
if [[ "$ep" == *"/openai/v1" || "$ep" == *"/v1" ]]; then
printf '%s' "$ep"
return
fi
if [[ "$ep" == *".cognitiveservices.azure.com"* || "$ep" == *".openai.azure.com"* || "$ep" == *".azure-api.net"* ]]; then
printf '%s/openai/v1' "$ep"
return
fi
printf '%s/v1' "$ep"
}
BASE_URL="$(normalize_base_url "$ENDPOINT")"

BACKEND_PORT="${DEEPSQL_BACKEND_PORT:-8080}"

echo "→ Repo: $REPO_ROOT"
echo "→ Agent home: $HERMES_HOME"
echo "→ Model base: $BASE_URL"
echo "→ Model: $MODEL @ $BASE_URL"

# Deep-merge the DBA config blocks into ~/.hermes/config.yaml (PyYAML ships with the agent).
REPO_ROOT="$REPO_ROOT" BASE_URL="$BASE_URL" AZURE_OPENAI_KEY="$AZURE_OPENAI_KEY" \
HERMES_HOME="$HERMES_HOME" "$VENV_PY" - <<'PY'
REPO_ROOT="$REPO_ROOT" BASE_URL="$BASE_URL" API_KEY="$API_KEY" MODEL="$MODEL" \
BACKEND_PORT="$BACKEND_PORT" HERMES_HOME="$HERMES_HOME" "$VENV_PY" - <<'PY'
import os, yaml, pathlib
home = pathlib.Path(os.environ["HERMES_HOME"]); repo = os.environ["REPO_ROOT"]
cfg_path = home / "config.yaml"
cfg = yaml.safe_load(cfg_path.read_text()) if cfg_path.exists() else {}
cfg = cfg or {}
cfg["model"] = {
"default": "gpt-5.4", "provider": "custom",
"base_url": os.environ["BASE_URL"], "api_key": os.environ["AZURE_OPENAI_KEY"],
"api_mode": "chat_completions", "context_length": 272000,
"default": os.environ["MODEL"],
"provider": "custom",
"base_url": os.environ["BASE_URL"],
"api_key": os.environ["API_KEY"],
"api_mode": "chat_completions",
"context_length": 272000,
}
cfg.setdefault("providers", {})["custom"] = {
"base_url": os.environ["BASE_URL"],
"api_key": os.environ["API_KEY"],
}
# Keep an existing DEEPSQL_AUTH_TOKEN if a prior setup-agent run wrote one into
# the root config; otherwise leave token unset — setup-agent.sh provisions the
# per-user profile with a minted token.
existing_env = ((cfg.get("mcp_servers") or {}).get("deepsql") or {}).get("env") or {}
mcp_env = {
"DEEPSQL_API_BASE_URL": f"http://localhost:{os.environ['BACKEND_PORT']}/api/",
"DEEPSQL_MCP_USER_ID": existing_env.get("DEEPSQL_MCP_USER_ID", "deepsql-agent"),
"DEEPSQL_MCP_PROJECT_ID": existing_env.get("DEEPSQL_MCP_PROJECT_ID", "deepsql-agent"),
}
if existing_env.get("DEEPSQL_AUTH_TOKEN"):
mcp_env["DEEPSQL_AUTH_TOKEN"] = existing_env["DEEPSQL_AUTH_TOKEN"]
cfg.setdefault("mcp_servers", {})["deepsql"] = {
"command": "node",
"args": [f"{repo}/mcp/deepsql-phase1-server.js"],
"env": {"DEEPSQL_API_BASE_URL": "http://localhost:8080/api/",
"DEEPSQL_MCP_USER_ID": "deepsql-agent", "DEEPSQL_MCP_PROJECT_ID": "deepsql-agent"},
"env": mcp_env,
}
cfg.setdefault("skills", {})["external_dirs"] = [f"{repo}/agent/skills"]
cfg.setdefault("approvals", {})["mode"] = "smart"
Expand All @@ -63,10 +123,11 @@ PY
cp "$REPO_ROOT/agent/SOUL.md" "$HERMES_HOME/SOUL.md"
echo " SOUL.md installed"

# Scope to a read-only sandbox: disable host-affecting toolsets.
( cd "$AGENT_DIR" && UV_NO_CONFIG=1 "$VENV_PY" -m hermes_cli.main tools disable \
terminal file code_execution browser computer_use image_gen tts vision web delegation cronjob \
>/dev/null 2>&1 ) || echo " (toolset disable skipped — disable manually with 'hermes tools disable ...')"
echo " host toolsets disabled (read-only deepsql + memory/todo/skills remain)"

echo "✓ DeepSQL Agent customization installed. Verify: (cd $AGENT_DIR && uv run hermes mcp test deepsql)"
echo "✓ DeepSQL Agent customization installed."
echo " Verify: (cd $AGENT_DIR && uv run hermes mcp test deepsql)"
echo " Or run: scripts/self-host/setup-agent.sh (starts webui + provisions MCP token)"
32 changes: 29 additions & 3 deletions backend/src/main/java/com/dbaagent/service/AgentChatClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
Expand All @@ -32,12 +34,23 @@
public class AgentChatClient {
private static final Logger log = LoggerFactory.getLogger(AgentChatClient.class);

// Cookie jar: the agent scopes sessions by hermes_profile. Without
// /api/profile/switch first, session/new(profile=u-…) then chat/start
// returns 404 "Session not found" under the default profile.
private final CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build();
.connectTimeout(Duration.ofSeconds(5))
.cookieHandler(cookies)
.build();
private final ObjectMapper objectMapper = new ObjectMapper();

/** Internal base URL of the agent webui (compose network). */
@Value("${agent.webui-url:http://deepsql-agent:8787}")
/**
* Base URL of the agent webui. Self-host Compose sets
* {@code AGENT_WEBUI_URL=http://host.docker.internal:8787} (no deepsql-agent
* service in the four-container stack). Override for a dedicated agent
* container on the compose network.
*/
@Value("${agent.webui-url:http://host.docker.internal:8787}")
private String webuiUrl;

/** Hard ceiling on a single agent turn for a channel reply. */
Expand Down Expand Up @@ -67,6 +80,13 @@ public String ensureSession(String profile, String existingSessionId) {
}

public SessionAttempt ensureSessionDetailed(String profile, String existingSessionId) {
try {
switchProfile(profile);
} catch (Exception e) {
log.warn("Could not switch agent profile to {} (webui={}): {}",
profile, webuiUrl, e.toString());
return new SessionAttempt(null, describe(e));
}
if (existingSessionId != null && !existingSessionId.isBlank()) {
return new SessionAttempt(existingSessionId, null);
}
Expand Down Expand Up @@ -101,6 +121,12 @@ public SessionAttempt ensureSessionDetailed(String profile, String existingSessi
}
}

/** Activate the hermes_profile cookie for subsequent webui API calls. */
private void switchProfile(String profile) throws Exception {
if (profile == null || profile.isBlank()) return;
postJson("/api/profile/switch", Map.of("name", profile));
}

/**
* Start a turn and block until it finishes (or times out), returning the
* assembled assistant text and the tool steps it ran.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ private BrainJobStatus toStatus(BrainJob job, String connectionId, ScheduledTask
statusReason = "Recurring job is not registered in db-scheduler";
}

// db-scheduler leaves consecutive_failures NULL until the first
// success/failure is recorded; coerce before unboxing into the int field.
int consecutiveFailures = row != null && row.consecutiveFailures() != null
? row.consecutiveFailures()
: 0;

return new BrainJobStatus(
job.key(),
job.title(),
Expand All @@ -165,7 +171,7 @@ private BrainJobStatus toStatus(BrainJob job, String connectionId, ScheduledTask
row != null ? row.executionTime() : null,
row != null ? row.lastSuccess() : null,
row != null ? row.lastFailure() : null,
row != null ? row.consecutiveFailures() : 0,
consecutiveFailures,
status,
statusReason
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,38 @@ void listJobs_marksMetadataRefreshRunningAndSchemaDriftActiveByDefault() throws
assertEquals("Recurring job is not registered in db-scheduler", driftCheck.statusReason());
}

@Test
void listJobs_toleratesNullConsecutiveFailures() throws Exception {
OffsetDateTime nextRun = OffsetDateTime.of(2026, 4, 8, 9, 30, 0, 0, ZoneOffset.UTC);

when(schemaChangeTrackingService.ensureDefaultDriftConfig("conn-1"))
.thenReturn(new SchemaDriftConfig());
when(jdbcTemplate.query(anyString(), org.mockito.ArgumentMatchers.<RowMapper<Object>>any()))
.thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("task_name")).thenReturn("brain-refresh-metadata-lifecycle");
when(rs.getObject("execution_time", OffsetDateTime.class)).thenReturn(nextRun);
when(rs.getBoolean("picked")).thenReturn(false);
when(rs.getObject("last_success", OffsetDateTime.class)).thenReturn(null);
when(rs.getObject("last_failure", OffsetDateTime.class)).thenReturn(null);
when(rs.getObject("consecutive_failures")).thenReturn(null);
when(rs.getObject("last_heartbeat", OffsetDateTime.class)).thenReturn(null);
return List.of(mapper.mapRow(rs, 0));
});

List<BrainJobsService.BrainJobStatus> jobs = service.listJobs("conn-1");

BrainJobsService.BrainJobStatus metadataRefresh = jobs.stream()
.filter(job -> "metadata_refresh".equals(job.key()))
.findFirst()
.orElseThrow();

assertEquals("active", metadataRefresh.status());
assertEquals(0, metadataRefresh.consecutiveFailures());
}

@Test
void runJob_dispatchesMetadataRefreshForConnection() {
BrainJobsService.ManualRunResult result = service.runJob("conn-1", "metadata_refresh");
Expand Down
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,18 @@ services:
# CORS — allow the frontend service (and optional custom domain)
cors.allowed.origins: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}

# Dashboard generation + Slack/channels call Hermes server-side (AgentChatClient),
# not via the browser /agent-api proxy. Point at the host webui; there is no
# deepsql-agent Compose service in the four-service self-host stack.
AGENT_WEBUI_URL: ${AGENT_WEBUI_URL:-http://host.docker.internal:8787}

# Self-host should default to the hardened production profile
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
# Automatically set by install.sh for pgvector self-host mode
SPRING_AUTOCONFIGURE_EXCLUDE: ${SPRING_AUTOCONFIGURE_EXCLUDE:-}
# So AGENT_WEBUI_URL=http://host.docker.internal:8787 resolves on Linux too.
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${DEEPSQL_BACKEND_PORT:-8080}:8080"
volumes:
Expand All @@ -112,6 +120,11 @@ services:
depends_on:
backend:
condition: service_healthy
# nginx proxies /agent-api/ → http://host.docker.internal:8787 (see docker/nginx/default.conf).
# The agent is optional and usually runs on the host (hermes-webui on :8787).
# Docker Desktop injects host.docker.internal; on Linux Compose needs this mapping.
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${DEEPSQL_FRONTEND_PORT:-3000}:80"

Expand Down
24 changes: 13 additions & 11 deletions docker/nginx/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -71,25 +71,27 @@ server {
proxy_set_header Accept "application/json";
}

# Agent chat proxy → the DeepSQL Agent service.
# Agent chat proxy → the DeepSQL Agent service (Hermes webui on :8787).
# Mirrors the dev Vite proxy: strip the /agent-api prefix so
# /agent-api/api/chat/stream reaches the agent's /api/chat/stream.
# SSE buffering MUST be off for token streaming.
location /agent-api/ {
# Require a valid DeepSQL session before reaching the agent.
auth_request /__agent_auth;

# The agent is an optional, separately-run service — the default Compose
# stack does not include it. nginx resolves proxy_pass hostnames at config
# load time, so a literal upstream here makes the whole container refuse to
# start when the agent is absent ("host not found in upstream"). Going
# through a variable defers resolution to request time: without an agent
# this one route returns 502 and everything else keeps working.
resolver 127.0.0.11 ipv6=off valid=10s;
set $deepsql_agent http://deepsql-agent:8787;
proxy_pass $deepsql_agent/;
# The agent is optional and usually runs on the Docker host, not as a
# Compose service. A variable + Docker DNS (127.0.0.11) cannot see
# /etc/hosts entries, so `extra_hosts: deepsql-agent:host-gateway` alone
# still 502s. Literal proxy_pass uses getaddrinfo (hosts file + DNS).
# Compose maps host.docker.internal → host-gateway so this always
# resolves at nginx start; if nothing listens on :8787 this route
# returns 502 and the rest of the UI keeps working.
proxy_pass http://host.docker.internal:8787/;
proxy_http_version 1.1;
proxy_set_header Host $host;
# Hermes CSRF compares Origin host:port to Host. `$host` drops the port
# (localhost vs localhost:3000) and profile/switch returns 403
# "Cross-origin mismatch". `$http_host` preserves the browser Host.
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
Expand Down
Loading
Loading