Line data Source code
1 : import * as React from 'react';
2 :
3 : import { AgentClient, AgentConnectionStatus } from './agent_client.js';
4 : import {
5 : AGENT_WEBSOCKET_PATH,
6 : type AgentClientSession,
7 : type AgentServerInfo,
8 : type AgentSessionContext,
9 : type AgentTurnState,
10 : } from './agent_protocol.js';
11 : import { isDesktopHost } from '../platform/native_globals.js';
12 : import { useLogger } from '../platform/logger/logger_provider.js';
13 : import { DASHQL_AGENT_PORT } from '../globals.js';
14 : import { useAppConfig, useAppReconfigure } from '../config/app_config.js';
15 : import { useConnectionRegistryStore } from '../connections/connection_registry.js';
16 : import { useEmbeddedConnection } from '../connections/embedded_connection.js';
17 : import { useQueryExecutor } from '../connections/query_executor.js';
18 : import { useRouteContext } from '../app/router/router.js';
19 : import { AgentQuestionStore } from './agent_question_store.js';
20 : import { useSessionScriptsDispatch, useSessionScriptsRegistry, useSessionScriptsRegistryReader } from '../session/scripts/session_scripts_registry.js';
21 : import { runAgentCommand, type AgentCommandEnvironment } from './agent_commands.js';
22 :
23 1 : const LOG_CTX = 'agent_bridge';
24 :
25 : /// Follows a turn that the page started
26 : export interface AgentTurnListener {
27 : /// The turn is queued or running, with the latest status line
28 : status: (state: AgentTurnState, text: string) => void;
29 : /// The agent wrote a script, every time it edits the script
30 : edited: (scriptKey: number) => void;
31 : /// The agent runs a script, every time it runs the script
32 : ran: (scriptKey: number) => void;
33 : /// The turn finished with the final message of the agent
34 : done: (message: string) => void;
35 : /// The turn failed or was stopped
36 : failed: (error: string, cancelled: boolean) => void;
37 : }
38 :
39 : /// The agent bridge of the page
40 : export interface AgentBridge {
41 : /// The connection to the agent server
42 : status: AgentConnectionStatus;
43 : /// Can this browser reach the agent server? Safari blocks ws://127.0.0.1 from https pages.
44 : supported: boolean;
45 : /// The port of the agent server
46 : port: number;
47 : /// Connect to the agent server on another port, null restores the build's default
48 : setPort: (port: number | null) => void;
49 : /// The harness that runs requests, null for the default harness of the agent server
50 : harness: string | null;
51 : /// Pick the harness that runs requests among the configured harnesses, null restores the server's default
52 : setHarness: (harness: string | null) => void;
53 : /// The model of the harness that runs requests, null for the harness's own default
54 : model: string | null;
55 : /// Pick among the models of the harness, null restores the harness's default
56 : setModel: (model: string | null) => void;
57 : /// The effort of the harness that runs requests, null for the harness's own default
58 : effort: string | null;
59 : /// Pick among the efforts of the harness, null restores the harness's default
60 : setEffort: (effort: string | null) => void;
61 : /// The sessions with agent access, the name is null while a session is not loaded
62 : accessSessions: { id: string; name: string | null }[];
63 : /// Does a session have agent access?
64 : hasAccess: (sessionId: string) => boolean;
65 : /// Turn agent access of a session on or off
66 : setAccess: (sessionId: string, enabled: boolean) => void;
67 : /// Run the harness with a prompt in a session, resolves with the turn id
68 : request: (sessionId: string, prompt: string, listener: AgentTurnListener) => Promise<string>;
69 : /// Stop a turn of a session, or all turns of the session without a turn id
70 : cancel: (sessionId: string, turnId?: string) => Promise<void>;
71 : /// Forget the harness context of a session, the next request starts a new harness session
72 : reset: (sessionId: string) => Promise<void>;
73 : /// The harness context of a session
74 : context: (sessionId: string) => Promise<AgentSessionContext>;
75 : /// The configuration and the state of the agent server
76 : info: () => Promise<AgentServerInfo>;
77 : /// Stay connected while no session has agent access, returns the release
78 : hold: () => () => void;
79 : /// The questions that the agent answers in the sessions
80 : questions: AgentQuestionStore;
81 : }
82 :
83 1 : const AGENT_BRIDGE_CTX = React.createContext<AgentBridge | null>(null);
84 :
85 : /// Provides an agent bridge, the provider below or a fake in tests
86 1 : export const AgentBridgeContext = AGENT_BRIDGE_CTX;
87 :
88 : /// The agent bridge, null outside of the provider
89 1 : export const useAgentBridge = (): AgentBridge | null => React.useContext(AGENT_BRIDGE_CTX);
90 :
91 : /// Does the browser block WebSockets to 127.0.0.1 from https pages?
92 1 : export function blocksLoopbackWebSockets(userAgent: string, desktop: boolean): boolean {
93 1 : if (desktop) return false;
94 1 : return /Safari\//.test(userAgent) && !/Chrome\/|Chromium\/|CriOS\/|Edg\/|Firefox\/|FxiOS\//.test(userAgent);
95 1 : }
96 :
97 1 : const NO_SESSIONS: string[] = [];
98 :
99 1 : export const AgentBridgeProvider: React.FC<React.PropsWithChildren> = (props: React.PropsWithChildren) => {
100 1 : const logger = useLogger();
101 1 : const config = useAppConfig();
102 1 : const reconfigure = useAppReconfigure();
103 1 : const route = useRouteContext();
104 1 : const [registry] = useSessionScriptsRegistry();
105 1 : const readRegistry = useSessionScriptsRegistryReader();
106 1 : const modifySessionScripts = useSessionScriptsDispatch();
107 1 : const connectionStore = useConnectionRegistryStore();
108 1 : const embeddedConnection = useEmbeddedConnection();
109 1 : const executeQuery = useQueryExecutor();
110 1 : const [status, setStatus] = React.useState(AgentConnectionStatus.Off);
111 1 : const supported = !blocksLoopbackWebSockets(globalThis.navigator?.userAgent ?? '', isDesktopHost());
112 :
113 1 : const accessSessions = config?.settings?.agentSessions ?? NO_SESSIONS;
114 1 : const port = config?.settings?.agentPort ?? DASHQL_AGENT_PORT;
115 1 : const harness = config?.settings?.agentHarness ?? null;
116 1 : const model = config?.settings?.agentModel ?? null;
117 1 : const effort = config?.settings?.agentEffort ?? null;
118 :
119 : // The listeners of the turns by turn id, and the turn ids of the requests of a session
120 1 : const turns = React.useRef(new Map<string, AgentTurnListener>());
121 1 : const turnsBySession = React.useRef(new Map<string, Set<string>>());
122 1 : const [questions] = React.useState(() => new AgentQuestionStore());
123 :
124 1 : const commandEnv = React.useRef<AgentCommandEnvironment | null>(null);
125 1 : commandEnv.current = {
126 1 : sessionScripts: sessionId => readRegistry().sessionScriptsMap.get(sessionId) ?? null,
127 1 : modifySessionScripts,
128 1 : connections: () => connectionStore.current,
129 1 : embeddedConnection,
130 1 : executeQuery,
131 1 : logger,
132 1 : onScriptEdited: (sessionId, scriptKey) => {
133 1 : for (const turnId of turnsBySession.current.get(sessionId) ?? []) {
134 1 : turns.current.get(turnId)?.edited(scriptKey);
135 : }
136 1 : },
137 1 : onScriptRun: (sessionId, scriptKey) => {
138 0 : for (const turnId of turnsBySession.current.get(sessionId) ?? []) {
139 0 : turns.current.get(turnId)?.ran(scriptKey);
140 : }
141 0 : },
142 1 : };
143 :
144 1 : const forgetTurn = React.useCallback((sessionId: string, turnId: string) => {
145 1 : turns.current.delete(turnId);
146 1 : const sessionTurns = turnsBySession.current.get(sessionId);
147 1 : sessionTurns?.delete(turnId);
148 1 : if (sessionTurns?.size === 0) turnsBySession.current.delete(sessionId);
149 1 : }, []);
150 :
151 1 : const client = React.useMemo(() => {
152 1 : if (!supported) return null;
153 1 : return new AgentClient({
154 1 : url: `ws://127.0.0.1:${port}${AGENT_WEBSOCKET_PATH}`,
155 1 : clientKind: isDesktopHost() ? 'desktop' : 'web',
156 1 : logger,
157 1 : handleCommand: async params => {
158 1 : logger.info('Running an agent command', { session: params.session, command: params.command }, LOG_CTX);
159 1 : const started = performance.now();
160 1 : try {
161 1 : const result = await runAgentCommand(commandEnv.current!, params);
162 1 : logger.info('Ran an agent command', { session: params.session, command: params.command, durationMs: String(Math.round(performance.now() - started)) }, LOG_CTX);
163 1 : return result;
164 1 : } catch (e: unknown) {
165 1 : logger.warn('An agent command failed', { session: params.session, command: params.command, durationMs: String(Math.round(performance.now() - started)), error: e instanceof Error ? e.message : String(e) }, LOG_CTX);
166 1 : throw e;
167 1 : }
168 1 : },
169 1 : onNotification: notification => {
170 1 : switch (notification.method) {
171 1 : case 'agent/status': {
172 1 : const p = notification.params;
173 1 : const turn = turns.current.get(p.turnId);
174 1 : if (turn == null) {
175 1 : logger.info('Ignored the status of an unknown agent turn', { session: p.session, turnId: p.turnId }, LOG_CTX);
176 1 : break;
177 1 : }
178 1 : turn.status(p.state, p.text);
179 1 : break;
180 1 : }
181 1 : case 'agent/done': {
182 1 : const p = notification.params;
183 1 : const turn = turns.current.get(p.turnId);
184 1 : if (turn == null) {
185 1 : logger.info('Ignored the answer of an unknown agent turn', { session: p.session, turnId: p.turnId }, LOG_CTX);
186 1 : break;
187 1 : }
188 1 : forgetTurn(p.session, p.turnId);
189 1 : logger.info('The agent finished a request', { session: p.session, turnId: p.turnId }, LOG_CTX);
190 1 : turn.done(p.message);
191 1 : break;
192 1 : }
193 1 : case 'agent/failed': {
194 1 : const p = notification.params;
195 1 : const turn = turns.current.get(p.turnId);
196 1 : if (turn == null) {
197 1 : logger.info('Ignored the failure of an unknown agent turn', { session: p.session, turnId: p.turnId, error: p.error }, LOG_CTX);
198 1 : break;
199 1 : }
200 1 : forgetTurn(p.session, p.turnId);
201 1 : logger.warn('The agent failed a request', { session: p.session, turnId: p.turnId, error: p.error, cancelled: String(p.cancelled) }, LOG_CTX);
202 1 : turn.failed(p.error, p.cancelled);
203 1 : break;
204 1 : }
205 1 : }
206 1 : },
207 1 : onStatus: setStatus,
208 : // A new connection hears nothing of the turns of the old one, the server may have stopped them
209 1 : onDisconnect: reason => {
210 1 : for (const [turnId, turn] of [...turns.current]) {
211 1 : logger.warn('Lost an agent turn', { turnId, reason }, LOG_CTX);
212 1 : turn.failed(reason, false);
213 1 : }
214 1 : turns.current.clear();
215 1 : turnsBySession.current.clear();
216 1 : },
217 1 : });
218 1 : }, [supported, port, logger, forgetTurn]);
219 1 : React.useEffect(() => () => client?.dispose(), [client]);
220 :
221 : // Announce the sessions with agent access and the focused session
222 1 : const announced = React.useMemo<AgentClientSession[]>(() => {
223 1 : const sessions: AgentClientSession[] = [];
224 1 : for (const sessionId of accessSessions) {
225 1 : const scripts = registry.sessionScriptsMap.get(sessionId);
226 1 : if (scripts != null) sessions.push({ id: sessionId, name: scripts.name ?? '' });
227 1 : }
228 1 : return sessions;
229 1 : }, [accessSessions, registry]);
230 1 : const announcedKey = JSON.stringify(announced);
231 1 : const focused = route.sessionId ?? null;
232 1 : React.useEffect(() => {
233 1 : client?.setSessions(announced, focused);
234 1 : }, [client, announcedKey, focused]);
235 :
236 1 : const [holds, setHolds] = React.useState(0);
237 1 : React.useEffect(() => {
238 1 : client?.setHeld(holds > 0);
239 1 : }, [client, holds]);
240 1 : const hold = React.useCallback(() => {
241 1 : setHolds(n => n + 1);
242 1 : return () => setHolds(n => n - 1);
243 1 : }, []);
244 :
245 1 : const setPort = React.useCallback((next: number | null) => {
246 1 : logger.info('Changed the port of the agent server', { port: String(next ?? DASHQL_AGENT_PORT) }, LOG_CTX);
247 1 : reconfigure(c => c == null ? c : { ...c, settings: { ...(c.settings ?? {}), agentPort: next ?? undefined } });
248 1 : }, [reconfigure, logger]);
249 1 : const setHarness = React.useCallback((next: string | null) => {
250 1 : logger.info('Changed the harness of the agent', { harness: next ?? 'default' }, LOG_CTX);
251 1 : reconfigure(c => {
252 1 : if (c == null) return c;
253 : // The models and efforts are the ones of a harness, another harness starts with its defaults
254 1 : const settings = { ...(c.settings ?? {}), agentHarness: next ?? undefined };
255 1 : if ((c.settings?.agentHarness ?? null) !== next) {
256 1 : settings.agentModel = undefined;
257 1 : settings.agentEffort = undefined;
258 1 : }
259 1 : return { ...c, settings };
260 1 : });
261 1 : }, [reconfigure, logger]);
262 1 : const setModel = React.useCallback((next: string | null) => {
263 1 : logger.info('Changed the model of the agent', { model: next ?? 'default' }, LOG_CTX);
264 1 : reconfigure(c => c == null ? c : { ...c, settings: { ...(c.settings ?? {}), agentModel: next ?? undefined } });
265 1 : }, [reconfigure, logger]);
266 1 : const setEffort = React.useCallback((next: string | null) => {
267 1 : logger.info('Changed the effort of the agent', { effort: next ?? 'default' }, LOG_CTX);
268 1 : reconfigure(c => c == null ? c : { ...c, settings: { ...(c.settings ?? {}), agentEffort: next ?? undefined } });
269 1 : }, [reconfigure, logger]);
270 :
271 1 : const accessSessionList = React.useMemo(
272 1 : () => accessSessions.map(id => ({ id, name: registry.sessionScriptsMap.get(id)?.name ?? null })),
273 1 : [accessSessions, registry],
274 1 : );
275 1 : const accessSessionListKey = JSON.stringify(accessSessionList);
276 : // Keep the identity while the names are unchanged, the registry changes with every edit
277 1 : const stableAccessSessions = React.useMemo(() => accessSessionList, [accessSessionListKey]);
278 :
279 1 : const hasAccess = React.useCallback((sessionId: string) => accessSessions.includes(sessionId), [accessSessions]);
280 1 : const setAccess = React.useCallback((sessionId: string, enabled: boolean) => {
281 1 : logger.info(enabled ? 'Turned on agent access' : 'Turned off agent access', { sessionId }, LOG_CTX);
282 1 : reconfigure(c => {
283 1 : if (c == null) return c;
284 1 : const current = c.settings?.agentSessions ?? [];
285 1 : const agentSessions = enabled
286 1 : ? (current.includes(sessionId) ? current : [...current, sessionId])
287 1 : : current.filter(id => id !== sessionId);
288 1 : return { ...c, settings: { ...(c.settings ?? {}), agentSessions } };
289 1 : });
290 1 : }, [reconfigure, logger]);
291 :
292 : // A deleted session takes its agent state along: the server stops its turns and removes its folder, its questions
293 : // and its agent access go. Only a deletion drops a session from the registry. A session deleted while the agent
294 : // server is not connected leaves its folder, the server removes the folders of unused sessions after a while.
295 1 : const knownSessions = React.useRef<ReadonlySet<string>>(new Set());
296 1 : const latestAccessSessions = React.useRef(accessSessions);
297 1 : latestAccessSessions.current = accessSessions;
298 1 : React.useEffect(() => {
299 1 : const sessions = new Set(registry.sessionScriptsMap.keys());
300 1 : for (const sessionId of knownSessions.current) {
301 1 : if (sessions.has(sessionId)) continue;
302 1 : logger.info('Forgetting the agent state of a deleted session', { sessionId }, LOG_CTX);
303 1 : if (client?.status === AgentConnectionStatus.Connected) {
304 1 : client.request('agent/forget', { session: sessionId }).catch((e: unknown) => {
305 1 : logger.warn('Forgetting a deleted session in the agent server failed', { sessionId, error: e instanceof Error ? e.message : String(e) }, LOG_CTX);
306 1 : });
307 : }
308 1 : questions.drop(sessionId);
309 1 : if (latestAccessSessions.current.includes(sessionId)) setAccess(sessionId, false);
310 1 : }
311 1 : knownSessions.current = sessions;
312 1 : }, [registry.sessionScriptsMap, client, questions, setAccess, logger]);
313 :
314 1 : const request = React.useCallback(async (sessionId: string, prompt: string, listener: AgentTurnListener) => {
315 1 : if (client == null) throw new Error('This browser cannot reach the agent server');
316 1 : logger.info('Sending a request to the agent', { session: sessionId, harness: harness ?? 'default', model: model ?? 'default', effort: effort ?? 'default', promptLength: String(prompt.length) }, LOG_CTX);
317 1 : const params = {
318 1 : session: sessionId,
319 1 : prompt,
320 1 : ...(harness != null ? { harness } : {}),
321 1 : ...(model != null ? { model } : {}),
322 1 : ...(effort != null ? { effort } : {}),
323 1 : };
324 1 : const result = await client.request('agent/request', params).catch((e: unknown) => {
325 1 : logger.warn('The agent server refused a request', { session: sessionId, error: e instanceof Error ? e.message : String(e) }, LOG_CTX);
326 1 : throw e;
327 1 : });
328 1 : turns.current.set(result.turnId, listener);
329 1 : const sessionTurns = turnsBySession.current.get(sessionId) ?? new Set();
330 1 : sessionTurns.add(result.turnId);
331 1 : turnsBySession.current.set(sessionId, sessionTurns);
332 1 : logger.info('Sent a request to the agent', { session: sessionId, turnId: result.turnId, harness: result.harness }, LOG_CTX);
333 1 : return result.turnId;
334 1 : }, [client, harness, model, effort, logger]);
335 1 : const cancel = React.useCallback(async (sessionId: string, turnId?: string) => {
336 1 : logger.info('Stopping agent turns', { session: sessionId, turnId: turnId ?? 'all' }, LOG_CTX);
337 1 : await client?.request('agent/cancel', turnId == null ? { session: sessionId } : { session: sessionId, turnId });
338 1 : }, [client, logger]);
339 1 : const reset = React.useCallback(async (sessionId: string) => {
340 1 : logger.info('Resetting the agent context', { session: sessionId }, LOG_CTX);
341 1 : await client?.request('agent/reset', { session: sessionId });
342 1 : }, [client, logger]);
343 1 : const context = React.useCallback(async (sessionId: string) => {
344 1 : if (client == null) throw new Error('This browser cannot reach the agent server');
345 1 : return client.request('agent/context', { session: sessionId, ...(harness != null ? { harness } : {}) });
346 1 : }, [client, harness]);
347 :
348 1 : const info = React.useCallback(async () => {
349 1 : if (client == null) throw new Error('This browser cannot reach the agent server');
350 1 : return client.request('agent/info', {});
351 1 : }, [client]);
352 :
353 1 : const bridge = React.useMemo<AgentBridge>(() => ({
354 1 : status,
355 1 : supported,
356 1 : port,
357 1 : setPort,
358 1 : harness,
359 1 : setHarness,
360 1 : model,
361 1 : setModel,
362 1 : effort,
363 1 : setEffort,
364 1 : accessSessions: stableAccessSessions,
365 1 : hasAccess,
366 1 : setAccess,
367 1 : request,
368 1 : cancel,
369 1 : reset,
370 1 : context,
371 1 : info,
372 1 : hold,
373 1 : questions,
374 1 : }), [status, supported, port, setPort, harness, setHarness, model, setModel, effort, setEffort, stableAccessSessions, hasAccess, setAccess, request, cancel, reset, context, info, hold, questions]);
375 :
376 1 : return <AGENT_BRIDGE_CTX.Provider value={bridge}>{props.children}</AGENT_BRIDGE_CTX.Provider>;
377 1 : };
|