import type { ImageInput } from "../llm/serviceShared.ts"; import { runBrowseAgent } from "../agents/browseAgent.ts"; import type { LLMService } from "../llm.ts"; import type { BrowserManager } from "../services/BrowserManager.ts"; import { createAbortError, isAbortError, throwIfAborted } from "./abortError.ts";
type BrowserTaskActionEntry = { kind: string; guildId?: string | null; channelId?: string | null; userId?: string | null; content?: string; metadata?: Record<string, unknown>; usdCost?: number; };
type BrowserTaskTrace = { guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string | null; };
type BrowserTaskStore = { logAction: (entry: BrowserTaskActionEntry) => void; };
type BrowserBrowseTaskOptions = { llm: LLMService; browserManager: BrowserManager; store: BrowserTaskStore; sessionKey: string; instruction: string; provider: string; model: string; headed?: boolean; profile?: string; maxSteps: number; stepTimeoutMs: number; sessionTimeoutMs?: number; trace: BrowserTaskTrace; logSource?: string | null; signal?: AbortSignal; };
type BrowserBrowseTaskResult = { text: string; steps: number; totalCostUsd: number; hitStepLimit: boolean; imageInputs?: ImageInput[]; };
type ActiveBrowserTask = { taskId: string; scopeKey: string; abortController: AbortController; };
let browserTaskCounter = 0;
export function describeBrowserTaskError(error: unknown) { const rawName = String((error as { name?: unknown })?.name || "").trim(); const rawMessage = error instanceof Error ? String(error.message || "").trim() : String(error || "").trim(); const rawCode = String((error as { code?: unknown })?.code || "").trim();
return { errorName: rawName || "Error", errorMessage: rawMessage || rawName || "unknown_browser_error", errorCode: rawCode || null }; }
export async function readBrowserCurrentUrl( browserManager: BrowserManager, sessionKey: string, stepTimeoutMs: number, fallbackUrl: string | null = null, signal?: AbortSignal ) { const currentUrlReader = (browserManager as { currentUrl?: BrowserManager["currentUrl"] }).currentUrl; if (typeof currentUrlReader !== "function") { return fallbackUrl; }
try { const currentUrl = await currentUrlReader.call(browserManager, sessionKey, stepTimeoutMs, signal); const normalizedCurrentUrl = String(currentUrl || "").trim(); return normalizedCurrentUrl || fallbackUrl; } catch { return fallbackUrl; } }
export function buildBrowserTaskScopeKey({
guildId,
channelId
}: {
guildId?: string | null;
channelId?: string | null;
}) {
const normalizedGuildId = String(guildId || "dm").trim() || "dm";
const normalizedChannelId = String(channelId || "dm").trim() || "dm";
return browser:${normalizedGuildId}:${normalizedChannelId};
}
export class BrowserTaskRegistry { private readonly tasksByScope = new Map<string, ActiveBrowserTask>();
beginTask(scopeKey: string) { const normalizedScopeKey = String(scopeKey || "").trim(); if (!normalizedScopeKey) { throw new Error("missing_browser_task_scope_key"); }
const existingTask = this.tasksByScope.get(normalizedScopeKey);
if (existingTask) {
existingTask.abortController.abort("Superseded by a newer browser task in the same channel");
}
browserTaskCounter += 1;
const task: ActiveBrowserTask = {
taskId: `${normalizedScopeKey}:${Date.now()}:${browserTaskCounter}`,
scopeKey: normalizedScopeKey,
abortController: new AbortController()
};
this.tasksByScope.set(normalizedScopeKey, task);
return task;
}
get(scopeKey: string) { return this.tasksByScope.get(String(scopeKey || "").trim()); }
abort(scopeKey: string, reason = "Browser task cancelled by user") { const task = this.get(scopeKey); if (!task) return false; task.abortController.abort(reason); this.tasksByScope.delete(task.scopeKey); return true; }
clear(task: ActiveBrowserTask | null | undefined) { if (!task) return; const currentTask = this.tasksByScope.get(task.scopeKey); if (!currentTask) return; if (currentTask.taskId !== task.taskId) return; this.tasksByScope.delete(task.scopeKey); } }
export async function runBrowserBrowseTask({ llm, browserManager, store, sessionKey, instruction, provider, model, headed, profile, maxSteps, stepTimeoutMs, sessionTimeoutMs, trace, logSource, signal }: BrowserBrowseTaskOptions): Promise { throwIfAborted(signal); const startedAt = Date.now();
try { const result = await runBrowseAgent({ llm, browserManager, store, sessionKey, instruction, provider, model, headed, profile, maxSteps, stepTimeoutMs, sessionTimeoutMs, trace, signal });
store.logAction({
kind: "browser_browse_call",
guildId: trace.guildId || null,
channelId: trace.channelId || null,
userId: trace.userId || null,
content: instruction.slice(0, 200),
metadata: {
sessionKey,
runtime: "local_browser_agent",
provider,
model,
steps: result.steps,
hitStepLimit: result.hitStepLimit,
imageInputCount: Array.isArray(result.imageInputs) ? result.imageInputs.length : 0,
totalCostUsd: result.totalCostUsd,
source: logSource ?? trace.source ?? null,
durationMs: Math.max(0, Date.now() - startedAt)
},
usdCost: result.totalCostUsd
});
return result;
} catch (error) { if (isAbortError(error) || signal?.aborted) { throw createAbortError(signal?.reason || error); }
const { errorName, errorMessage, errorCode } = describeBrowserTaskError(error);
const currentUrl = await readBrowserCurrentUrl(browserManager, sessionKey, stepTimeoutMs, null, signal);
store.logAction({
kind: "browser_browse_failed",
guildId: trace.guildId || null,
channelId: trace.channelId || null,
userId: trace.userId || null,
content: instruction.slice(0, 200),
metadata: {
sessionKey,
runtime: "local_browser_agent",
provider,
model,
currentUrl,
source: logSource ?? trace.source ?? null,
durationMs: Math.max(0, Date.now() - startedAt),
errorName,
errorMessage,
errorCode
}
});
throw error;
} }
