src/bot/agentTasks.ts

import { BrowserAgentSession } from "../agents/browseAgent.ts"; import { runOpenAiComputerUseTask } from "../tools/openAiComputerUseRuntime.ts"; import { isAbortError } from "../tools/abortError.ts"; import { buildBrowserTaskScopeKey, runBrowserBrowseTask } from "../tools/browserTaskRuntime.ts"; import { clamp } from "../utils.ts"; import { MAX_BROWSER_BROWSE_QUERY_LEN, normalizeDirectiveText } from "./botHelpers.ts"; import { getResolvedBrowserTaskConfig, isMinecraftEnabled, getMinecraftConfig, getMinecraftProjectActionBudget, getMinecraftServerCatalog } from "../settings/agentStack.ts"; import { createMinecraftSession as createMinecraftSessionRuntime } from "../agents/minecraft/minecraftSession.ts"; import { buildMinecraftSessionScopeKey, findReusableMinecraftSession } from "../agents/minecraft/minecraftSessionAccess.ts"; import { createMinecraftNarrationState, maybePostMinecraftNarration, type MinecraftNarrationRuntime } from "./minecraftNarration.ts"; import { createMinecraftBrain } from "../agents/minecraft/minecraftBrain.ts"; import { createMinecraftBuilder } from "../agents/minecraft/minecraftBuilder.ts"; import { resolveMinecraftMcpServer, type MinecraftMcpProcess } from "../agents/minecraft/minecraftMcpProcess.ts"; import type { BrowserBrowseContextState } from "./budgetTracking.ts"; import type { AgentContext } from "./botContext.ts";

type AgentTaskTrace = { guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string | null; };

type RunModelRequestedBrowserBrowseOptions = { settings: Record<string, unknown>; browserBrowse: BrowserBrowseContextState; query?: string; guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string; signal?: AbortSignal; };

type BrowserBrowseState = BrowserBrowseContextState;

type CreateBrowserAgentSessionOptions = { settings: Record<string, unknown>; guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string; };

function buildScopeKey({ guildId, channelId }: { guildId?: string | null; channelId?: string | null; }) { return ${guildId || "dm"}:${channelId || "dm"}; }

function buildTrace({ guildId, channelId = null, userId = null, source = null }: AgentTaskTrace = {}) { return { guildId, channelId, userId, source }; }

export async function runModelRequestedBrowserBrowse( ctx: AgentContext, { settings, browserBrowse, query, guildId, channelId = null, userId = null, source = "reply_message", signal }: RunModelRequestedBrowserBrowseOptions ) { const normalizedQuery = normalizeDirectiveText(query, MAX_BROWSER_BROWSE_QUERY_LEN); const state: BrowserBrowseState = { ...browserBrowse, requested: true, used: false, blockedByBudget: false, query: normalizedQuery, text: "", imageInputs: [], steps: 0, hitStepLimit: false, error: null };

if (!state.enabled || !state.configured || !ctx.browserManager) { return state; } if (!state.budget?.canBrowse) { return { ...state, blockedByBudget: true }; } if (!normalizedQuery) { return { ...state, error: "Missing browser browse query." }; } if (!ctx.llm) { return { ...state, error: "llm_unavailable" }; }

const browserTaskConfig = getResolvedBrowserTaskConfig(settings); const maxSteps = clamp(Number(browserTaskConfig.maxStepsPerTask) || 15, 1, 30); const stepTimeoutMs = clamp(Number(browserTaskConfig.stepTimeoutMs) || 30_000, 5_000, 120_000); const computerUseClient = browserTaskConfig.runtime === "openai_computer_use" ? ctx.llm.getComputerUseClient(browserTaskConfig.openaiComputerUse.client) : null; if (browserTaskConfig.runtime === "openai_computer_use" && !computerUseClient?.client) { return { ...state, error: "openai_computer_use_unavailable" }; }

const scopeKey = buildBrowserTaskScopeKey({ guildId, channelId }); const activeBrowserTask = ctx.activeBrowserTasks.beginTask(scopeKey); const taskSignal = signal ? AbortSignal.any([activeBrowserTask.abortController.signal, signal]) : activeBrowserTask.abortController.signal;

try { const sessionKey = reply:${activeBrowserTask.taskId}; const trace = buildTrace({ guildId, channelId, userId, source: ${source}_browser_browse }); const result = browserTaskConfig.runtime === "openai_computer_use" ? await runOpenAiComputerUseTask({ openai: computerUseClient.client, provider: computerUseClient.provider || "openai", browserManager: ctx.browserManager, store: ctx.store, sessionKey, instruction: normalizedQuery, model: browserTaskConfig.openaiComputerUse.model, headed: browserTaskConfig.headed, profile: browserTaskConfig.profile || undefined, maxSteps, stepTimeoutMs, sessionTimeoutMs: browserTaskConfig.sessionTimeoutMs, trace, logSource: source, signal: taskSignal }) : await runBrowserBrowseTask({ llm: ctx.llm, browserManager: ctx.browserManager, store: ctx.store, sessionKey, instruction: normalizedQuery, provider: browserTaskConfig.localAgent.provider, model: browserTaskConfig.localAgent.model, headed: browserTaskConfig.headed, profile: browserTaskConfig.profile || undefined, maxSteps, stepTimeoutMs, sessionTimeoutMs: browserTaskConfig.sessionTimeoutMs, trace, logSource: source, signal: taskSignal });

return {
  ...state,
  used: true,
  text: result.text,
  imageInputs: Array.isArray(result.imageInputs) ? result.imageInputs : [],
  steps: result.steps,
  hitStepLimit: result.hitStepLimit
};

} catch (error) { if (isAbortError(error)) { return { ...state, cancelled: true, error: "Browser session cancelled by user." }; } return { ...state, cancelled: false, error: String(error?.message || error) }; } finally { ctx.activeBrowserTasks.clear(activeBrowserTask); } }

function createBrowserAgentSession( ctx: AgentContext, { settings, guildId, channelId = null, userId = null, source = "reply_session" }: CreateBrowserAgentSessionOptions ) { if (!ctx.browserManager) return null; const browserTaskConfig = getResolvedBrowserTaskConfig(settings); if (browserTaskConfig.runtime === "openai_computer_use") return null; const maxSteps = clamp(Number(browserTaskConfig.maxStepsPerTask) || 15, 1, 30); const stepTimeoutMs = clamp(Number(browserTaskConfig.stepTimeoutMs) || 30_000, 5_000, 120_000);

const scopeKey = buildScopeKey({ guildId, channelId }); const sessionKey = session:${scopeKey}:${Date.now()}; return new BrowserAgentSession({ scopeKey, llm: ctx.llm, browserManager: ctx.browserManager, store: ctx.store, sessionKey, provider: browserTaskConfig.localAgent.provider, model: browserTaskConfig.localAgent.model, headed: browserTaskConfig.headed, profile: browserTaskConfig.profile || undefined, maxSteps, stepTimeoutMs, sessionTimeoutMs: browserTaskConfig.sessionTimeoutMs, trace: buildTrace({ guildId, channelId, userId, source }) }); }

export type CreateMinecraftSessionOptions = { settings: Record<string, unknown>; guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string; };

/**

  • How many recent Discord messages to expose to the Minecraft brain as
  • cross-surface context. Kept small so the brain's prompt stays compact —
  • the brain only needs enough history to connect follow-ups across surfaces. */ const MINECRAFT_DISCORD_CONTEXT_LIMIT = 10;

// ── Lazy Minecraft MCP lifecycle ───────────────────────────────────────────── // The MCP server is spawned on first minecraft_task call, not at bot startup. // A singleton resolver ensures only one spawn happens even under concurrent // requests.

let minecraftMcpSingleton: { baseUrl: string; process: MinecraftMcpProcess | null } | null = null; let minecraftMcpSpawnPromise: Promise<{ baseUrl: string; process: MinecraftMcpProcess | null } | null> | null = null;

async function ensureMinecraftMcpServer( settings: Record<string, unknown>, logAction: (entry: Record<string, unknown>) => void ): Promise<string | null> { if (!isMinecraftEnabled(settings)) return null;

// Already resolved. if (minecraftMcpSingleton) return minecraftMcpSingleton.baseUrl;

// Another caller is already spawning — wait for it. if (minecraftMcpSpawnPromise) { const result = await minecraftMcpSpawnPromise; return result?.baseUrl ?? null; }

const config = getMinecraftConfig(settings); minecraftMcpSpawnPromise = (async () => { try { const result = await resolveMinecraftMcpServer({ explicitUrl: config.mcpUrl, logAction, mcHost: config.serverTarget?.host ?? undefined, mcPort: config.serverTarget?.port ?? undefined }); minecraftMcpSingleton = result; return result; } catch (error) { logAction({ kind: "minecraft_mcp_init_error", content: String((error as Error)?.message || error) }); return null; } finally { minecraftMcpSpawnPromise = null; } })();

const result = await minecraftMcpSpawnPromise; return result?.baseUrl ?? null; }

/**

  • Stop the auto-spawned MCP server (if any). Call on bot shutdown. */ export async function stopMinecraftMcpServer(): Promise { if (minecraftMcpSingleton?.process) { await minecraftMcpSingleton.process.stop(); } minecraftMcpSingleton = null; minecraftMcpSpawnPromise = null; }

async function createMinecraftSession( ctx: AgentContext, { settings, guildId, channelId = null, userId = null, source = "reply_session" }: CreateMinecraftSessionOptions ) { if (!isMinecraftEnabled(settings)) return null;

const scopeKey = buildMinecraftSessionScopeKey({ guildId, channelId }); const existingSession = findReusableMinecraftSession(ctx.subAgentSessions, { ownerUserId: userId, scopeKey }); if (existingSession) return existingSession;

const logAction = (entry: Record<string, unknown>) => ctx.store.logAction({ ...entry, guildId, channelId, userId, source });

// Lazy-spawn the MCP server on first use. const baseUrl = await ensureMinecraftMcpServer(settings, logAction); if (!baseUrl) return null;

const config = getMinecraftConfig(settings); const narrationState = createMinecraftNarrationState(); const narrationRuntime: MinecraftNarrationRuntime = { ...ctx, client: ctx.client as MinecraftNarrationRuntime["client"] };

// Cross-surface Discord context: only for guild-scoped sessions. DM/owner-private // scopes deliberately get no Discord context because MC chat may be visible to // other players the brain shouldn't leak private conversation to. const getRecentDiscordContext = guildId && channelId ? () => { const rows = ctx.store.getRecentMessages(channelId, MINECRAFT_DISCORD_CONTEXT_LIMIT); // getRecentMessages returns DESC (newest first); flip to chronological // so the brain reads the prompt section like a conversation. return rows.reverse().map((row) => ({ speaker: row.author_name, text: row.content, timestamp: row.created_at, isBot: row.is_bot })); } : undefined;

return createMinecraftSessionRuntime({ scopeKey, baseUrl, ownerUserId: userId, knownIdentities: config.knownIdentities.map((entry) => ({ mcUsername: entry.mcUsername, ...(entry.discordUsername ? { discordUsername: entry.discordUsername } : {}), ...(entry.label ? { label: entry.label } : {}), ...(entry.relationship ? { relationship: entry.relationship } : {}), ...(entry.notes ? { notes: entry.notes } : {}) })), serverTarget: config.serverTarget, serverCatalog: getMinecraftServerCatalog(settings), logAction, onGameEvent: (events, context) => { logAction({ kind: "minecraft_game_events", content: events.map((event) => [${event.type}] ${event.summary}).join("; "), metadata: { events, eventCount: events.length } }); void maybePostMinecraftNarration(narrationRuntime, { guildId, channelId, ownerUserId: userId, scopeKey, source, serverLabel: config.serverTarget?.label || config.serverTarget?.host || null, events, chatHistory: context?.chatHistory, state: narrationState }).catch((error) => { logAction({ kind: "bot_error", content: minecraft_narration: ${String(error instanceof Error ? error.message : error)}, metadata: { scopeKey, events } }); }); }, getRecentDiscordContext, brain: createMinecraftBrain( ctx.llm, () => ctx.store.getSettings() ), builder: createMinecraftBuilder( ctx.llm, () => ctx.store.getSettings() ), projectActionBudget: getMinecraftProjectActionBudget(settings) }); }

export function buildSubAgentSessionsRuntime(ctx: AgentContext) { return { manager: ctx.subAgentSessions, createBrowserSession: (opts: CreateBrowserAgentSessionOptions) => createBrowserAgentSession(ctx, opts), createMinecraftSession: (opts: CreateMinecraftSessionOptions) => createMinecraftSession(ctx, opts) }; }