Skip to content

Commit f9cfed0

Browse files
fix(agent): make CLI agent turns work via profile cookie + provisioner recovery
AgentChatClient now switches hermes_profile (CookieManager) before session/chat calls so CLI/Slack match the browser Agent tab. Surface SSE error events instead of empty answers. Local provisioner recovers corrupt profile config.yaml (which dropped model: and caused Missed model deployment) and validates dumps. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent 2afdb3c commit f9cfed0

3 files changed

Lines changed: 88 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,24 @@ only covers cloud-specific, non-obvious caveats.
235235
`http://deepsql-agent:8788/provision`) with `AGENT_PROVISION_SECRET`. In this VM run
236236
`python3 scripts/local-agent-provisioner.py` (needs those two env vars in `.env`).
237237
Without it, Spring logs `agent.provision-secret is unset — skipping…` and the
238-
`u-admin` agent profile is never created/token-refreshed.
238+
`u-admin` agent profile is never created/token-refreshed. If provision returns HTTP
239+
500 with a PyYAML parse error on `config.yaml`, the profile is corrupt (often a
240+
mangled `agent.personalities` block that also drops `model:`) — the provisioner
241+
now restores from `~/.hermes/config.yaml`. Manual recovery: copy that default
242+
over `~/.hermes/profiles/u-<user>/config.yaml` and re-POST `/provision`. Symptom
243+
of a bad profile: Hermes logs `Missed model deployment` and CLI agent returns
244+
empty / “ended before producing an answer”.
245+
- **`AGENT_WEBUI_URL` for native runs.** Default is `http://deepsql-agent:8787`
246+
(Compose DNS). Native local must set `AGENT_WEBUI_URL=http://127.0.0.1:8787` in
247+
`.env` or CLI/Slack `AgentChatClient` cannot reach the agent API.
248+
- **DeepSQL CLI (`deepsql`) for agent testing.** Install from the repo package:
249+
`cd mcp && DEEPSQL_SKIP_AGENT_SETUP=1 npm install -g .` (prefix
250+
`~/.npm-global`, keep that on `PATH`). Auth against local backend with an MCP
251+
token (`POST /api/auth/mcp-tokens` when auth is disabled stores into
252+
`~/.config/deepsql/auth.json`). One-shot:
253+
`deepsql agent --connection <uuid> "…"`. Interactive: `deepsql` / `deepsql agent`.
254+
The CLI is a thin client over `POST /api/agent/chat` (not a local agent runtime);
255+
backend + agent API (:8787) + provisioner must already be up.
239256
- **Spring CORS must allow both loopback hosts.** Set
240257
`CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000` in `.env`. Opening
241258
the UI as `http://127.0.0.1:3000` while only `localhost` is allowlisted yields **403**

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

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import java.io.BufferedReader;
1111
import java.io.InputStream;
1212
import java.io.InputStreamReader;
13+
import java.net.CookieManager;
14+
import java.net.CookiePolicy;
1315
import java.net.URI;
1416
import java.net.URLEncoder;
1517
import java.net.http.HttpClient;
@@ -24,16 +26,23 @@
2426
/**
2527
* Synchronous client for the DeepSQL Agent webui API, reached over the compose
2628
* network (internal — not via the gated /agent-api proxy). Used by non-browser
27-
* channels (Slack, etc.) that need a single final answer rather than a live SSE
29+
* channels (Slack, CLI, etc.) that need a single final answer rather than a live SSE
2830
* stream: it starts a turn, consumes the stream server-side, and returns the
2931
* assembled text plus a compact tool-step summary.
32+
*
33+
* <p>Sessions are scoped by the upstream {@code hermes_profile} cookie. This client
34+
* keeps a {@link CookieManager} and calls {@code /api/profile/switch} before
35+
* session/chat calls so CLI/Slack turns see the same profile Spring provisioned
36+
* ({@code u-<user>}), matching the browser Agent tab.
3037
*/
3138
@Service
3239
public class AgentChatClient {
3340
private static final Logger log = LoggerFactory.getLogger(AgentChatClient.class);
3441

3542
private final HttpClient http = HttpClient.newBuilder()
36-
.connectTimeout(Duration.ofSeconds(5)).build();
43+
.connectTimeout(Duration.ofSeconds(5))
44+
.cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_ALL))
45+
.build();
3746
private final ObjectMapper objectMapper = new ObjectMapper();
3847

3948
/** Internal base URL of the agent webui (compose network). */
@@ -49,11 +58,6 @@ public record AgentReply(boolean ok, String text, List<String> toolSteps, String
4958
public static AgentReply fail(String error) { return new AgentReply(false, null, List.of(), error); }
5059
}
5160

52-
/**
53-
* Return a usable session id for the given profile: reuse {@code existingSessionId}
54-
* when present, otherwise create a fresh session and auto-approve its read-only
55-
* tool surface (channels are non-interactive). Returns null on failure.
56-
*/
5761
/**
5862
* Why a session could not be created, for callers that surface a reason to the user.
5963
* {@code sessionId} non-null means success and {@code failureReason} is null.
@@ -62,11 +66,18 @@ public record SessionAttempt(String sessionId, String failureReason) {
6266
boolean ok() { return sessionId != null; }
6367
}
6468

69+
/** Convenience wrapper around {@link #ensureSessionDetailed}. */
6570
public String ensureSession(String profile, String existingSessionId) {
6671
return ensureSessionDetailed(profile, existingSessionId).sessionId();
6772
}
68-
6973
public SessionAttempt ensureSessionDetailed(String profile, String existingSessionId) {
74+
try {
75+
switchProfile(profile);
76+
} catch (Exception e) {
77+
log.warn("Could not switch agent profile {} at {}: {}", profile, webuiUrl, e.toString());
78+
log.debug("agent profile/switch failure detail", e);
79+
return new SessionAttempt(null, "could not switch agent profile: " + describe(e));
80+
}
7081
if (existingSessionId != null && !existingSessionId.isBlank()) {
7182
return new SessionAttempt(existingSessionId, null);
7283
}
@@ -101,6 +112,13 @@ public SessionAttempt ensureSessionDetailed(String profile, String existingSessi
101112
}
102113
}
103114

115+
/** Bind subsequent requests to {@code profile} via the upstream profile cookie. */
116+
private void switchProfile(String profile) throws Exception {
117+
if (profile == null || profile.isBlank()) {
118+
throw new IllegalArgumentException("agent profile is required");
119+
}
120+
postJson("/api/profile/switch", Map.of("name", profile));
121+
}
104122
/**
105123
* Start a turn and block until it finishes (or times out), returning the
106124
* assembled assistant text and the tool steps it ran.
@@ -116,7 +134,7 @@ public AgentReply sendAndAwait(String sessionId, String message) {
116134
return AgentReply.fail("agent did not start a turn");
117135
}
118136
} catch (Exception e) {
119-
return AgentReply.fail("could not start agent turn: " + e.getMessage());
137+
return AgentReply.fail("could not start agent turn: " + describe(e));
120138
}
121139
return consumeStream(streamId);
122140
}
@@ -131,6 +149,7 @@ private AgentReply consumeStream(String streamId) {
131149
StringBuilder answer = new StringBuilder();
132150
List<String> toolSteps = new ArrayList<>();
133151
boolean ended = false;
152+
String streamError = null;
134153
try {
135154
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
136155
if (resp.statusCode() / 100 != 2) {
@@ -156,6 +175,13 @@ private AgentReply consumeStream(String streamId) {
156175
// after the LAST tool is the real reply.
157176
answer.setLength(0);
158177
}
178+
case "error", "apperror" -> {
179+
String err = textField(data, "message");
180+
if (err == null || err.isBlank()) err = textField(data, "error");
181+
if (err == null || err.isBlank()) err = data;
182+
streamError = err;
183+
ended = true;
184+
}
159185
case "stream_end", "done" -> ended = true;
160186
default -> { /* metering, context_status, interim_assistant, title — ignore */ }
161187
}
@@ -166,8 +192,11 @@ private AgentReply consumeStream(String streamId) {
166192
} catch (Exception e) {
167193
return AgentReply.fail("agent stream error: " + e.getMessage());
168194
}
195+
if (streamError != null && !streamError.isBlank()) {
196+
return AgentReply.fail("agent runtime error: " + streamError);
197+
}
169198
String text = answer.toString().trim();
170-
if (text.isEmpty() && !ended) {
199+
if (text.isEmpty()) {
171200
return AgentReply.fail("agent run ended before producing an answer");
172201
}
173202
return AgentReply.ok(text, toolSteps);

scripts/local-agent-provisioner.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,37 @@ def write_profile_env(home: Path, *, user: str, token: str) -> None:
7474
os.chmod(env_path, 0o600)
7575

7676

77+
def _load_profile_config(home: Path):
78+
"""Load profile config.yaml, recovering from a corrupt file by cloning the default.
79+
80+
A prior dump race (or a partial write) can leave personas/model keys mangled so
81+
PyYAML refuses to parse. Without recovery, every subsequent /provision 500s and
82+
the agent profile never gets a fresh MCP token — which surfaces to CLI users as
83+
empty agent turns, not as a clear provisioner error.
84+
"""
85+
import yaml # agent venv / system PyYAML
86+
87+
cfg_path = home / "config.yaml"
88+
default_path = HERMES_HOME / "config.yaml"
89+
if cfg_path.exists():
90+
try:
91+
cfg = yaml.safe_load(cfg_path.read_text())
92+
if isinstance(cfg, dict) and cfg.get("model"):
93+
return cfg
94+
except Exception as e:
95+
sys.stderr.write(f"[agent-provisioner] corrupt {cfg_path}: {e}; restoring from default\n")
96+
if default_path.exists():
97+
cfg = yaml.safe_load(default_path.read_text()) or {}
98+
else:
99+
cfg = {}
100+
return cfg if isinstance(cfg, dict) else {}
101+
102+
77103
def write_profile_mcp(home: Path, *, user: str, token: str) -> None:
78104
import yaml # agent venv / system PyYAML
79105

80106
cfg_path = home / "config.yaml"
81-
cfg = yaml.safe_load(cfg_path.read_text()) if cfg_path.exists() else {}
82-
cfg = cfg or {}
107+
cfg = _load_profile_config(home)
83108
# Token must live on the MCP subprocess env — the agent runtime does not auto-forward
84109
# the profile .env into mcp_servers.*.env.
85110
cfg.setdefault("mcp_servers", {})["deepsql"] = {
@@ -94,7 +119,10 @@ def write_profile_mcp(home: Path, *, user: str, token: str) -> None:
94119
}
95120
cfg.setdefault("skills", {})["external_dirs"] = [str(REPO_ROOT / "agent" / "skills")]
96121
cfg.setdefault("approvals", {})["mode"] = "smart"
97-
cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False))
122+
dumped = yaml.safe_dump(cfg, sort_keys=False, allow_unicode=True)
123+
# Refuse to write unparseable YAML — better a loud 500 than a silent corrupt profile.
124+
yaml.safe_load(dumped)
125+
cfg_path.write_text(dumped)
98126
soul_src = REPO_ROOT / "agent" / "SOUL.md"
99127
if soul_src.exists():
100128
(home / "SOUL.md").write_text(soul_src.read_text())

0 commit comments

Comments
 (0)