LCOV - code coverage report
Current view: top level - dashql-app/src/agent - agent_bridge.tsx Coverage Total Hit
Test: DashQL 6e74948 Lines: 98.9 % 262 259
Test Date: 2026-11-06 17:46:50

            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 : };
        

Generated by: LCOV version 2.0-1