Skip to content

Commit 8a62e31

Browse files
fix(agent): switch Hermes profile after bootstrap so Agent chat works
DeepSQL's Agent tab uses AgentChatPanel + agentClient against the Hermes HTTP API, not the Hermes webui overlay. Bootstrap returns profile u-<user>, but without POST /api/profile/switch Hermes keeps the default profile and session/yolo 404s (UI surfaces as a failed run / 500). Also document local agent provisioner + HERMES_WEBUI_ALLOWED_ORIGINS for native Cloud Agent setups, and add scripts/local-agent-provisioner.py. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent a028482 commit 8a62e31

3 files changed

Lines changed: 212 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,13 +209,22 @@ only covers cloud-specific, non-obvious caveats.
209209
`text-embedding-3-large`. Also set `AZURE_OPENAI_KEY` / `AZURE_OPENAI_ENDPOINT` aliases —
210210
`hermes/install.sh` reads those. After changing LLM env, restart the backend
211211
(`scripts/start-backend.sh`); `/api/setup/status` should show `hasLlmConfig: true`.
212-
- **Agent tab (Hermes) is optional but required for the in-app Agent chat UI.** Install via
213-
`curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --non-interactive --skip-setup`,
214-
symlink `~/.hermes/hermes-agent/.venv``venv` (DeepSQL's `hermes/install.sh` expects `.venv`),
215-
then `bash hermes/install.sh`. Start the webui with
216-
`HERMES_WEBUI_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000`
217-
(without this, Vite's Origin header makes Hermes return **403** "Cross-origin mismatch").
218-
Webui listens on `:8787`; Vite proxies `/agent-api` → there.
212+
- **Agent tab (Hermes HTTP API, DeepSQL's own React UI — `AgentChatPanel` via `agentClient.js`).**
213+
Not a skin on the Hermes webui. Flow: `POST /api/agent/session` (Spring provisions
214+
`u-<user>`) → `/agent-api/api/profile/switch` (sets `hermes_profile` cookie) →
215+
`session/new` / `chat/start` / SSE `chat/stream`. Without the profile switch, Hermes
216+
404s with "Session not found" because sessions are scoped to the active profile —
217+
that surfaces in the UI as a boot failure / early 500.
218+
- **Local provisioner required for native (non-Compose) runs.**
219+
`AgentBridgeService` POSTs to `AGENT_PROVISIONER_URL` (default Compose:
220+
`http://deepsql-agent:8788/provision`) with `AGENT_PROVISION_SECRET`. In this VM run
221+
`python3 scripts/local-agent-provisioner.py` (needs those two env vars in `.env`).
222+
Without it, Spring logs `agent.provision-secret is unset — skipping…` and the
223+
`u-admin` Hermes profile is never created/token-refreshed.
224+
- **Hermes webui Origin allowlist for Vite.** Start Hermes with
225+
`HERMES_WEBUI_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000` or browser
226+
requests via the Vite `/agent-api` proxy return **403** "Cross-origin mismatch".
227+
Webui listens on `:8787`.
219228
- **Before running backend tests that boot the Spring context** (e.g. `ApiSmokeTest`), stop
220229
the running backend first — both use `ddl-auto=update` on the same `dba_agent` DB and can
221230
deadlock on an `ALTER TABLE`. Test env vars are documented in `CLAUDE.md` (Testing).

scripts/local-agent-provisioner.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
#!/usr/bin/env python3
2+
"""Local DeepSQL Agent profile provisioner (native/dev stand-in for the agent container).
3+
4+
Production Compose runs an agent-side secret-gated provisioner on :8788 that
5+
AgentBridgeService POSTs to (see agent.provisioner-url). That binary is not in
6+
this OSS checkout. For Cursor Cloud / native local dev, this script provides the
7+
same contract so /api/agent/session can create `u-<user>` Hermes profiles with
8+
MCP credentials before the Agent tab opens.
9+
10+
Contract (matches AgentBridgeService.callProvisioner):
11+
POST /provision
12+
Header: X-Provision-Secret: <AGENT_PROVISION_SECRET>
13+
Body: { "user": "<username>", "token": "<mcp-or-jwt>", "connectionId": "<uuid>" }
14+
15+
Idempotent: creates the profile on first call (cloning default), then refreshes
16+
DEEPSQL_AUTH_TOKEN / DEEPSQL_API_BASE_URL / DEEPSQL_MCP_USER_ID in the profile .env.
17+
"""
18+
from __future__ import annotations
19+
20+
import json
21+
import os
22+
import re
23+
import subprocess
24+
import sys
25+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
26+
from pathlib import Path
27+
28+
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")).expanduser()
29+
REPO_ROOT = Path(os.environ.get("DEEPSQL_REPO_ROOT", Path(__file__).resolve().parents[1]))
30+
API_BASE = os.environ.get("DEEPSQL_API_BASE_URL", "http://localhost:8080/api/")
31+
SECRET = os.environ.get("AGENT_PROVISION_SECRET", "")
32+
HOST = os.environ.get("AGENT_PROVISIONER_HOST", "127.0.0.1")
33+
PORT = int(os.environ.get("AGENT_PROVISIONER_PORT", "8788"))
34+
HERMES_BIN = os.environ.get("HERMES_BIN", str(Path.home() / ".local/bin/hermes"))
35+
36+
37+
def profile_for(username: str) -> str:
38+
safe = re.sub(r"[^a-z0-9]+", "-", (username or "").lower()).strip("-")
39+
return f"u-{safe or 'user'}"
40+
41+
42+
def ensure_profile(name: str) -> Path:
43+
home = HERMES_HOME / "profiles" / name
44+
if home.exists():
45+
return home
46+
cmd = [HERMES_BIN, "profile", "create", name, "--clone", "--no-alias",
47+
"--description", f"DeepSQL Agent profile for {name}"]
48+
subprocess.run(cmd, check=True, env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ.get('PATH','')}"})
49+
return home
50+
51+
52+
def write_profile_env(home: Path, *, user: str, token: str) -> None:
53+
env_path = home / ".env"
54+
keys: dict[str, str] = {}
55+
if env_path.exists():
56+
for line in env_path.read_text().splitlines():
57+
if not line.strip() or line.strip().startswith("#") or "=" not in line:
58+
continue
59+
k, v = line.split("=", 1)
60+
keys[k.strip()] = v
61+
# Prefer workspace/.env Azure key if profile has none yet
62+
if not keys.get("AZURE_OPENAI_KEY") and not keys.get("OPENAI_API_KEY"):
63+
ws_env = REPO_ROOT / ".env"
64+
if ws_env.exists():
65+
for line in ws_env.read_text().splitlines():
66+
if line.startswith("AZURE_OPENAI_KEY=") or line.startswith("DEEPSQL_CHAT_API_KEY="):
67+
keys["AZURE_OPENAI_KEY"] = line.split("=", 1)[1]
68+
keys["OPENAI_API_KEY"] = keys["AZURE_OPENAI_KEY"]
69+
keys["DEEPSQL_API_BASE_URL"] = API_BASE
70+
keys["DEEPSQL_AUTH_TOKEN"] = token or ""
71+
keys["DEEPSQL_MCP_USER_ID"] = user
72+
keys["DEEPSQL_MCP_PROJECT_ID"] = "deepsql-agent"
73+
env_path.write_text("\n".join(f"{k}={v}" for k, v in keys.items()) + "\n")
74+
os.chmod(env_path, 0o600)
75+
76+
77+
def write_profile_mcp(home: Path, *, user: str, token: str) -> None:
78+
import yaml # Hermes venv / system PyYAML
79+
80+
cfg_path = home / "config.yaml"
81+
cfg = yaml.safe_load(cfg_path.read_text()) if cfg_path.exists() else {}
82+
cfg = cfg or {}
83+
# Token must live on the MCP subprocess env — Hermes does not auto-forward
84+
# the profile .env into mcp_servers.*.env.
85+
cfg.setdefault("mcp_servers", {})["deepsql"] = {
86+
"command": "node",
87+
"args": [str(REPO_ROOT / "mcp" / "deepsql-phase1-server.js")],
88+
"env": {
89+
"DEEPSQL_API_BASE_URL": API_BASE,
90+
"DEEPSQL_MCP_USER_ID": user,
91+
"DEEPSQL_MCP_PROJECT_ID": "deepsql-agent",
92+
"DEEPSQL_AUTH_TOKEN": token or "",
93+
},
94+
}
95+
cfg.setdefault("skills", {})["external_dirs"] = [str(REPO_ROOT / "hermes" / "skills")]
96+
cfg.setdefault("approvals", {})["mode"] = "smart"
97+
cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False))
98+
soul_src = REPO_ROOT / "hermes" / "SOUL.md"
99+
if soul_src.exists():
100+
(home / "SOUL.md").write_text(soul_src.read_text())
101+
102+
103+
class Handler(BaseHTTPRequestHandler):
104+
def log_message(self, fmt, *args):
105+
sys.stderr.write(f"[agent-provisioner] {self.address_string()} - {fmt % args}\n")
106+
107+
def _read_json(self):
108+
length = int(self.headers.get("Content-Length") or 0)
109+
raw = self.rfile.read(length) if length else b"{}"
110+
return json.loads(raw.decode("utf-8") or "{}")
111+
112+
def _send(self, code: int, body: dict):
113+
data = json.dumps(body).encode("utf-8")
114+
self.send_response(code)
115+
self.send_header("Content-Type", "application/json")
116+
self.send_header("Content-Length", str(len(data)))
117+
self.end_headers()
118+
self.wfile.write(data)
119+
120+
def do_GET(self):
121+
if self.path in ("/health", "/"):
122+
return self._send(200, {"ok": True, "service": "deepsql-local-agent-provisioner"})
123+
return self._send(404, {"error": "not found"})
124+
125+
def do_POST(self):
126+
if self.path.rstrip("/") != "/provision":
127+
return self._send(404, {"error": "not found"})
128+
if not SECRET:
129+
return self._send(500, {"error": "AGENT_PROVISION_SECRET unset"})
130+
if self.headers.get("X-Provision-Secret") != SECRET:
131+
return self._send(401, {"error": "unauthorized"})
132+
try:
133+
body = self._read_json()
134+
except Exception:
135+
return self._send(400, {"error": "invalid json"})
136+
user = str(body.get("user") or "").strip()
137+
token = str(body.get("token") or "")
138+
if not user:
139+
return self._send(400, {"error": "user required"})
140+
profile = profile_for(user)
141+
try:
142+
home = ensure_profile(profile)
143+
write_profile_mcp(home, user=user, token=token)
144+
write_profile_env(home, user=user, token=token)
145+
except Exception as e:
146+
return self._send(500, {"error": str(e)})
147+
return self._send(200, {"ok": True, "profile": profile, "home": str(home)})
148+
149+
150+
def main():
151+
if not SECRET:
152+
print("AGENT_PROVISION_SECRET is required", file=sys.stderr)
153+
sys.exit(1)
154+
# Prefer Hermes venv PyYAML
155+
venv_site = HERMES_HOME / "hermes-agent" / "venv" / "lib"
156+
if venv_site.exists():
157+
for p in venv_site.glob("python*/site-packages"):
158+
sys.path.insert(0, str(p))
159+
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
160+
print(f"[agent-provisioner] listening on http://{HOST}:{PORT}/provision", flush=True)
161+
httpd.serve_forever()
162+
163+
164+
if __name__ == "__main__":
165+
main()

src/lib/api/agentClient.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
// Client for the native "Agent" chat tab.
1+
// Client for the native "Agent" chat tab (AgentChatPanel — DeepSQL's own React UI).
22
//
33
// Two hops:
44
// 1. POST /api/agent/session → Spring backend (cookie auth) resolves/provisions
55
// the user's agent profile and returns { profile }.
6-
// 2. /agent-api/* → DeepSQL Agent chat service (Vite-proxied to :8787):
7-
// create a session, start a turn, stream it over SSE.
6+
// 2. /agent-api/* → the Hermes agent HTTP API (Vite-proxied to :8787):
7+
// profile/switch, session/new, chat/start, then chat/stream over SSE.
88
//
99
// SSE event shapes:
1010
// token { text }
@@ -38,6 +38,20 @@ async function postJson(url, body, _retried = false) {
3838
return res.json();
3939
}
4040

41+
/**
42+
* Bind the Hermes webui to this user's profile via the `hermes_profile` cookie.
43+
*
44+
* Hermes scopes session visibility to the active profile. Spring returns
45+
* `u-<username>` from /api/agent/session; if we create a session under that
46+
* profile but never switch, subsequent /api/session/yolo and /api/chat/start
47+
* calls 404 with "Session not found" (the Agent tab surfaces this as a boot
48+
* failure / early 500). credentials:"include" sends the Set-Cookie back.
49+
*/
50+
async function switchAgentProfile(profile) {
51+
if (!profile) return;
52+
await postJson(`${AGENT_BASE}/api/profile/switch`, { name: profile });
53+
}
54+
4155
/** Prepend a one-line connection context so the agent grounds on the active DB
4256
* without the user pasting a UUID (the provisioned USER.md isn't injected into
4357
* webui sessions). Sent to the agent only — the UI displays the raw message. */
@@ -50,11 +64,24 @@ export function withConnectionContext(message, connectionId, connectionName) {
5064
export const agentChatAPI = {
5165
/** Resolve/provision the current user's agent profile (via Spring → cookie auth). */
5266
async bootstrap(connectionId) {
53-
return postJson("/api/agent/session", { connectionId });
67+
const data = await postJson("/api/agent/session", { connectionId });
68+
// Must happen before any session/new / resume path that hits /agent-api.
69+
try {
70+
await switchAgentProfile(data?.profile);
71+
} catch {
72+
/* older agent / missing profile — newSession may still work on default */
73+
}
74+
return data;
5475
},
5576

5677
/** Create a lean DBA chat session for this profile; returns the session id. */
5778
async newSession(profile) {
79+
// Idempotent re-bind in case bootstrap's switch was skipped or the cookie aged out.
80+
try {
81+
await switchAgentProfile(profile);
82+
} catch {
83+
/* non-fatal */
84+
}
5885
const data = await postJson(`${AGENT_BASE}/api/session/new`, {
5986
profile,
6087
enabled_toolsets: ["deepsql", "skills"],

0 commit comments

Comments
 (0)