-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathagentClient.js
More file actions
203 lines (188 loc) · 7.92 KB
/
Copy pathagentClient.js
File metadata and controls
203 lines (188 loc) · 7.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
// Client for the native "Agent" chat tab (AgentChatPanel — DeepSQL's own React UI).
//
// Two hops:
// 1. POST /api/agent/session → Spring backend (cookie auth) resolves/provisions
// the user's agent profile and returns { profile }.
// 2. /agent-api/* → the DeepSQL Agent HTTP API (Vite-proxied to :8787;
// customized Hermes runtime): profile/switch, session/new, chat/start, then
// chat/stream over SSE.
//
// SSE event shapes:
// token { text }
// tool { event_type:"tool.started", name, args, tid }
// tool_complete { event_type:"tool.completed", name, preview, tid }
// stream_end | done → turn finished
import { requestSessionRefresh } from "./client";
const AGENT_BASE = "/agent-api";
const CSRF_HEADER = "X-Hermes-CSRF-Token";
/** Cached CSRF token for the agent API (required once trusted-auth is on). */
let agentCsrfToken = null;
/**
* Effective DeepSQL username from the last `/api/agent/session` bootstrap.
* Vite has no nginx `auth_request` to stamp `X-Remote-User`, so the browser
* must send it. Never hardcode a user — impersonation ("View as") changes this.
*/
let agentRemoteUser = null;
export function clearAgentRemoteUser() {
agentRemoteUser = null
agentCsrfToken = null
}
function withAgentAuthHeaders(headers = {}) {
if (agentRemoteUser) {
headers["X-Remote-User"] = agentRemoteUser;
}
return headers;
}
async function ensureAgentCsrf() {
if (agentCsrfToken) return agentCsrfToken;
const res = await fetch(`${AGENT_BASE}/api/auth/status`, {
credentials: "include",
headers: withAgentAuthHeaders(),
});
if (!res.ok) return null;
const data = await res.json().catch(() => ({}));
agentCsrfToken = data?.csrf_token || null;
return agentCsrfToken;
}
async function postJson(url, body, _retried = false) {
const headers = { "Content-Type": "application/json" };
// Browser fetch always sends Origin; once HERMES_WEBUI_TRUSTED_AUTH_HEADER
// enables the agent auth gate, unsafe POSTs need the session CSRF token or
// the agent answers 403 "Session expired - reload the page".
if (url.startsWith(AGENT_BASE) || url.includes("/agent-api/")) {
withAgentAuthHeaders(headers);
const csrf = await ensureAgentCsrf();
if (csrf) headers[CSRF_HEADER] = csrf;
}
const res = await fetch(url, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify(body || {}),
});
// /agent-api is gated by the DeepSQL session cookie (nginx auth_request). The
// 15-min access token can lapse while the Agent tab sits idle, so on a 401
// refresh the session once (deduped with the app-wide refresh) and retry. This
// keeps an actively-logged-in UI's agent working without a visible error.
if (res.status === 401 && !_retried) {
try {
await requestSessionRefresh();
} catch {
/* refresh failed — fall through and surface the original 401 */
}
agentCsrfToken = null;
return postJson(url, body, true);
}
// CSRF token can rotate when trusted-auth mints a fresh hermes_session.
if (res.status === 403 && !_retried && (url.startsWith(AGENT_BASE) || url.includes("/agent-api/"))) {
agentCsrfToken = null;
await ensureAgentCsrf();
return postJson(url, body, true);
}
if (!res.ok) {
// Surface the backend's clear-error JSON (e.g. agent_provisioning_failed)
// instead of a bare status code — the caller shows this text verbatim.
const errBody = await res.json().catch(() => null);
throw new Error(errBody?.message || errBody?.error || `${url} → ${res.status}`);
}
return res.json();
}
/**
* Bind the agent API to this user's profile via the upstream `hermes_profile` cookie.
*
* The agent scopes session visibility to the active profile. Spring returns
* `u-<username>` from /api/agent/session; if we create a session under that
* profile but never switch, subsequent /api/session/yolo and /api/chat/start
* calls 404 with "Session not found" (the Agent tab surfaces this as a boot
* failure / early 500). credentials:"include" sends the Set-Cookie back.
*/
async function switchAgentProfile(profile) {
if (!profile) return;
await postJson(`${AGENT_BASE}/api/profile/switch`, { name: profile });
}
/** Prepend a one-line connection context so the agent grounds on the active DB
* without the user pasting a UUID (the provisioned USER.md isn't injected into
* webui sessions). Sent to the agent only — the UI displays the raw message. */
export function withConnectionContext(message, connectionId, connectionName) {
if (!connectionId) return message;
const label = connectionName ? `${connectionName} (id ${connectionId})` : connectionId;
return `[Active DeepSQL connection: ${label}. Use this connection unless I name another.]\n\n${message}`;
}
export const agentChatAPI = {
/** Resolve/provision the current user's agent profile (via Spring → cookie auth). */
async bootstrap(connectionId) {
const data = await postJson("/api/agent/session", { connectionId });
if (data?.username) agentRemoteUser = data.username;
// Must happen before any session/new / resume path that hits /agent-api.
try {
await switchAgentProfile(data?.profile);
} catch {
/* older agent / missing profile — newSession may still work on default */
}
return data;
},
/** Re-export for callers that switch explicitly (e.g. AgentChatPanel boot). */
async switchProfile(profile) {
await switchAgentProfile(profile);
},
/** Create a lean DBA chat session for this profile; returns the session id. */
async newSession(profile) {
// Idempotent re-bind in case bootstrap's switch was skipped or the cookie aged out.
try {
await switchAgentProfile(profile);
} catch {
/* non-fatal */
}
const data = await postJson(`${AGENT_BASE}/api/session/new`, {
profile,
// Alias `deepsql` → `mcp-deepsql` once MCP is discovered; `skills` keeps
// the DBA skill surface. Omit host toolsets (terminal/file/etc.).
enabled_toolsets: ["deepsql", "skills"],
});
const sessionId = data?.session?.session_id || data?.session_id;
// Best-effort: auto-approve the read-only tool surface for this session.
try {
await postJson(`${AGENT_BASE}/api/session/yolo`, { session_id: sessionId, enabled: true });
} catch { /* non-fatal */ }
return sessionId;
},
/** Start a turn; returns the stream_id to subscribe to. */
async startChat(sessionId, message) {
const data = await postJson(`${AGENT_BASE}/api/chat/start`, {
session_id: sessionId,
message,
});
return data.stream_id;
},
/** Subscribe to a turn's SSE stream. Returns the EventSource (caller may .close()). */
streamChat(streamId, { onToken, onTool, onToolComplete, onEnd, onError } = {}) {
const qs = new URLSearchParams({ stream_id: streamId })
if (agentRemoteUser) qs.set('remote_user', agentRemoteUser)
const es = new EventSource(
`${AGENT_BASE}/api/chat/stream?${qs.toString()}`,
{ withCredentials: true },
);
let done = false;
const finish = () => {
if (done) return;
done = true;
es.close();
onEnd?.();
};
const parse = (e, cb) => { try { cb?.(JSON.parse(e.data)); } catch { /* ignore */ } };
es.addEventListener("token", (e) => parse(e, (d) => onToken?.(d.text || "")));
es.addEventListener("tool", (e) => parse(e, (d) => onTool?.(d)));
es.addEventListener("tool_complete", (e) => parse(e, (d) => onToolComplete?.(d)));
es.addEventListener("stream_end", finish);
es.addEventListener("done", finish);
es.addEventListener("error", () => {
// EventSource also fires "error" when the server closes after stream_end —
// only surface a real error if the turn hadn't finished.
if (!done) { done = true; es.close(); onError?.(new Error("stream error")); }
});
return es;
},
async cancel(streamId) {
try { await postJson(`${AGENT_BASE}/api/chat/cancel`, { stream_id: streamId }); } catch { /* ignore */ }
},
};