Skip to content

Commit 2afdb3c

Browse files
geekypunkclaude
andcommitted
fix(agent): say why the agent is unavailable instead of that it is
"the DeepSQL agent is unavailable right now" was returned for every failure, and it reads as a transient outage — so the honest response is to wait, when in fact nothing improves without someone changing a setting. Two failures that need opposite fixes were indistinguishable: nothing is listening (the runtime is down, or agent.webui-url points at a host that does not exist), versus something answered and refused (it wants a login the backend never performs). The log was no better: ensureSession logged e.getMessage(), which is null for an unresolved host, producing "Could not create agent session for profile u-admin: null". Now the response names the target and the cause: DeepSQL agent unavailable: cannot reach the agent runtime at http://deepsql-agent:8787 — is it running, and is agent.webui-url correct? DeepSQL agent unavailable: the agent runtime at http://127.0.0.1:8787 rejected the request: POST /api/session/new -> HTTP 401 {"detail":"Unauthorized"} Both were observed against a live backend. The first corrected a misdiagnosis made while investigating this very bug: the default agent.webui-url is the Compose service name, so on a native run the backend never reaches the runtime at all — which the old message had hidden behind "unavailable". ensureSession keeps its signature; ensureSessionDetailed carries the reason, so the Slack and dashboard callers are untouched. Also: * .env.example documents AGENT_WEBUI_URL, AGENT_PROVISIONER_URL and AGENT_PROVISION_SECRET. None were listed, yet no non-Compose run works without them, and their defaults are Compose hostnames. * agent/distribution.yaml pins hermes_requires to <0.20.0. Upstream v0.20.0 (2026.8.3) answers 401 no_cookie to every agent API call: it requires a login cookie, and AgentChatClient sends none — the Compose deployment relies on a reverse proxy injecting X-DeepSQL-User / X-DeepSQL-Token. The floor stays at 0.12.0 deliberately; nothing below has been tested, and raising it would be a guess presented as a fact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 82b876b commit 2afdb3c

4 files changed

Lines changed: 97 additions & 10 deletions

File tree

.env.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,27 @@ EMBEDDING_FAIL_OPEN=false
138138
# listed here on purpose: they still appear in application.properties, but no Java
139139
# code reads any azure.openai.* property any more, so setting them changes nothing.
140140

141+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
142+
# OPTIONAL — DeepSQL Agent runtime (chat TUI + the web Agent tab)
143+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
144+
# The agent surfaces — `deepsql agent` and the Agent tab — are served by a separate
145+
# runtime, not by the backend. The defaults below are Compose service names, so on a
146+
# native (non-Compose) run they resolve to nothing and every agent turn fails with
147+
# "cannot reach the agent runtime at http://deepsql-agent:8787". Point them at wherever
148+
# the runtime actually listens. Everything else in DeepSQL works without them.
149+
#
150+
# AGENT_WEBUI_URL where the agent runtime serves its API (default http://deepsql-agent:8787)
151+
# AGENT_PROVISIONER_URL per-user profile provisioning endpoint (default http://deepsql-agent:8788/provision)
152+
# AGENT_PROVISION_SECRET shared secret for the above. Unset, the backend logs
153+
# "agent.provision-secret is unset — skipping" and never creates
154+
# the u-<user> profile, so the agent has no identity to run as.
155+
#
156+
# Native runs: start the provisioner with `python3 scripts/local-agent-provisioner.py`.
157+
# See AGENTS.md for the full sequence, and agent/README.md for the runtime itself.
158+
#AGENT_WEBUI_URL=http://127.0.0.1:8787
159+
#AGENT_PROVISIONER_URL=http://127.0.0.1:8788/provision
160+
#AGENT_PROVISION_SECRET=
161+
141162
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
142163
# VECTOR STORE — choose one mode
143164
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

agent/distribution.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@
66
name: deepsql-agent
77
version: 0.1.0
88
description: "DeepSQL DBA agent — grounded, read-only database assistant over the DeepSQL MCP tools (BI queries, schema exploration, index advice, slow-query optimization, workload analysis)."
9-
hermes_requires: ">=0.12.0"
9+
# The upper bound is evidence, not caution: verified on 2026-08-06 against upstream
10+
# v0.20.0 (2026.8.3), where every agent API call answers
11+
# 401 {"detail":"Unauthorized","reason":"no_cookie"}. That release requires a login
12+
# cookie on /api/session/new and friends, and AgentChatClient authenticates in no way
13+
# at all — the Compose deployment relies on a reverse proxy injecting
14+
# X-DeepSQL-User / X-DeepSQL-Token, which does not exist on a native run.
15+
#
16+
# The floor stays at 0.12.0 because nothing below it has been tested; it is not a
17+
# claim that 0.12.0 works. Raise the ceiling only once the backend authenticates to
18+
# the runtime and a real turn has been seen to complete.
19+
hermes_requires: ">=0.12.0,<0.20.0"
1020
author: "DeepSQL"
1121
license: "proprietary"
1222

backend/src/main/java/com/dbaagent/service/AgentChatClient.java

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,21 @@ public record AgentReply(boolean ok, String text, List<String> toolSteps, String
5454
* when present, otherwise create a fresh session and auto-approve its read-only
5555
* tool surface (channels are non-interactive). Returns null on failure.
5656
*/
57+
/**
58+
* Why a session could not be created, for callers that surface a reason to the user.
59+
* {@code sessionId} non-null means success and {@code failureReason} is null.
60+
*/
61+
public record SessionAttempt(String sessionId, String failureReason) {
62+
boolean ok() { return sessionId != null; }
63+
}
64+
5765
public String ensureSession(String profile, String existingSessionId) {
66+
return ensureSessionDetailed(profile, existingSessionId).sessionId();
67+
}
68+
69+
public SessionAttempt ensureSessionDetailed(String profile, String existingSessionId) {
5870
if (existingSessionId != null && !existingSessionId.isBlank()) {
59-
return existingSessionId;
71+
return new SessionAttempt(existingSessionId, null);
6072
}
6173
try {
6274
JsonNode created = postJson("/api/session/new", Map.of(
@@ -66,18 +78,26 @@ public String ensureSession(String profile, String existingSessionId) {
6678
if (sessionId == null) sessionId = text(created.path("session_id"));
6779
if (sessionId == null) {
6880
log.warn("Agent session/new returned no session_id for profile {}", profile);
69-
return null;
81+
return new SessionAttempt(null,
82+
"the agent runtime accepted the request but returned no session id");
7083
}
7184
// Best-effort: auto-approve read-only tools so the turn doesn't block on approval.
7285
try {
7386
postJson("/api/session/yolo", Map.of("session_id", sessionId, "enabled", true));
7487
} catch (Exception e) {
7588
log.debug("session/yolo failed for {}: {}", sessionId, e.getMessage());
7689
}
77-
return sessionId;
90+
return new SessionAttempt(sessionId, null);
7891
} catch (Exception e) {
79-
log.warn("Could not create agent session for profile {}: {}", profile, e.getMessage());
80-
return null;
92+
// toString(), not getMessage(): a NullPointerException or a connect failure
93+
// carries a null message, and "Could not create agent session: null" says
94+
// nothing at all. The class name alone distinguishes "refused to connect"
95+
// from "rejected the request". The exception is attached so the stack is
96+
// available at DEBUG without making the common case unreadable.
97+
log.warn("Could not create agent session for profile {} at {}: {}",
98+
profile, webuiUrl, e.toString());
99+
log.debug("agent session/new failure detail", e);
100+
return new SessionAttempt(null, describe(e));
81101
}
82102
}
83103

@@ -163,11 +183,41 @@ private JsonNode postJson(String path, Map<String, Object> body) throws Exceptio
163183
.build();
164184
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
165185
if (resp.statusCode() / 100 != 2) {
166-
throw new RuntimeException("POST " + path + " -> HTTP " + resp.statusCode());
186+
// The body carries the reason the status code alone does not. An agent
187+
// runtime hardened to require a login answers
188+
// {"detail":"Unauthorized","reason":"no_cookie"} — without that, a 401 is
189+
// indistinguishable from the agent being down, which sends whoever is
190+
// debugging after the wrong fix. Truncated so an HTML error page cannot
191+
// land whole in the log.
192+
String detail = resp.body() == null ? "" : resp.body().strip();
193+
if (detail.length() > 300) detail = detail.substring(0, 300) + "…";
194+
throw new RuntimeException("POST " + path + " -> HTTP " + resp.statusCode()
195+
+ (detail.isEmpty() ? "" : " " + detail));
167196
}
168197
return objectMapper.readTree(resp.body());
169198
}
170199

200+
/**
201+
* A one-line reason fit to show an operator. The two failures look identical from
202+
* the outside and have opposite fixes: nothing is listening (start the runtime, or
203+
* agent.webui-url points at the wrong place) versus something answered and said no
204+
* (authenticate to it, or its API changed).
205+
*/
206+
private String describe(Exception e) {
207+
if (e instanceof java.net.ConnectException || e instanceof java.net.UnknownHostException) {
208+
return "cannot reach the agent runtime at " + webuiUrl
209+
+ " — is it running, and is agent.webui-url correct?";
210+
}
211+
if (e instanceof java.net.http.HttpTimeoutException) {
212+
return "the agent runtime at " + webuiUrl + " did not respond in time";
213+
}
214+
String msg = e.getMessage();
215+
if (msg == null || msg.isBlank()) {
216+
return "the agent runtime at " + webuiUrl + " failed with " + e.getClass().getSimpleName();
217+
}
218+
return "the agent runtime at " + webuiUrl + " rejected the request: " + msg;
219+
}
220+
171221
private String text(JsonNode node) {
172222
return (node == null || node.isMissingNode() || node.isNull()) ? null : node.asText(null);
173223
}

backend/src/main/java/com/dbaagent/service/AgentTurnService.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,17 @@ public TurnResult runTurn(String connectionId, String conversationId, String mes
5353
}
5454

5555
String existingSession = conv == null ? null : conv.getAgentSessionId();
56-
String sessionId = agentChatClient.ensureSession(profile, existingSession);
57-
if (sessionId == null) {
56+
// Detailed variant: "unavailable right now" reads as a transient outage and sends
57+
// people away to wait, when the real causes — the runtime is not running, the URL
58+
// is wrong, or it demands a login the backend never performs — stay broken until
59+
// somebody acts. Report what actually happened.
60+
AgentChatClient.SessionAttempt attempt =
61+
agentChatClient.ensureSessionDetailed(profile, existingSession);
62+
if (attempt.sessionId() == null) {
5863
return new TurnResult(false, null, List.of(), conv == null ? null : conv.getId(),
59-
"the DeepSQL agent is unavailable right now");
64+
"DeepSQL agent unavailable: " + attempt.failureReason());
6065
}
66+
String sessionId = attempt.sessionId();
6167

6268
if (conv == null) {
6369
conv = conversationService.create(connectionId, sessionId, deriveTitle(message));

0 commit comments

Comments
 (0)