import type { ImageInput } from "../llm/serviceShared.ts";
/**
- Unified SubAgentSession framework
- Provides a common interface for interactive, multi-turn sub-agent sessions
- (code agent, browser agent, future agents). The brain's tool loop sends a
- first turn, gets a result, and can optionally continue the conversation by
- passing follow-up messages using the same session_id.
- Sessions are kept alive with idle timeouts — the brain (LLM) decides when to
- continue vs accept the result, no explicit
needs_inputsignal is required - from sub-agents. */
export interface SubAgentTurnResult { text: string; costUsd: number; imageInputs?: ImageInput[]; isError: boolean; errorMessage: string; /** True when the sub-agent intentionally ended the session during this turn. */ sessionCompleted?: boolean; usage: SubAgentUsage; }
export type SubAgentProgressEvent = { kind?: string; summary?: string; message?: string; percent?: number; turnNumber?: number; elapsedMs?: number; timestamp?: number; filePath?: string; metadata?: Record<string, unknown>; };
export interface SubAgentTurnOptions { signal?: AbortSignal; onProgress?: (event: SubAgentProgressEvent) => void; }
export type SubAgentUsage = { inputTokens: number; outputTokens: number; cacheWriteTokens: number; cacheReadTokens: number; };
export const EMPTY_USAGE: SubAgentUsage = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
export interface SubAgentSession { readonly id: string; readonly type: "browser" | "minecraft"; readonly createdAt: number; /** The userId that created this session (for authorization checks). */ readonly ownerUserId: string | null; lastUsedAt: number; status: "idle" | "running" | "completed" | "error" | "cancelled"; getBrowserSessionKey?(): string | null; getPromptStateHint?(): string | null;
/** Send a turn (initial instruction or follow-up) and get the result. */ runTurn(input: string, options?: SubAgentTurnOptions): Promise;
/** Cancel any in-flight work and reject future turns. */ cancel(reason?: string): void;
/** Close the session and free resources. */ close(): void; }
const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const DEFAULT_MAX_SESSIONS = 20; const SWEEP_INTERVAL_MS = 60_000; // 1 minute
export class SubAgentSessionManager { private readonly sessions = new Map<string, SubAgentSession>(); private readonly idleTimeoutMs: number; private readonly maxSessions: number; private sweepTimer: ReturnType | null = null;
constructor({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS, maxSessions = DEFAULT_MAX_SESSIONS } = {}) { this.idleTimeoutMs = Math.max(10_000, idleTimeoutMs); this.maxSessions = Math.max(1, maxSessions); }
/** Start periodic cleanup of idle sessions. */ startSweep() { if (this.sweepTimer) return; this.sweepTimer = setInterval(() => this.sweepIdle(), SWEEP_INTERVAL_MS); if (this.sweepTimer.unref) this.sweepTimer.unref(); }
/** Stop periodic cleanup. */ stopSweep() { if (this.sweepTimer) { clearInterval(this.sweepTimer); this.sweepTimer = null; } }
/** Register a new session. Evicts the oldest idle session if at capacity. */ register(session: SubAgentSession): void { // If a session with this ID already exists, close the old one const existing = this.sessions.get(session.id); if (existing) { existing.close(); }
// Evict oldest idle session if at capacity
if (this.sessions.size >= this.maxSessions) {
this.evictOldest();
}
this.sessions.set(session.id, session);
}
/** Get a session by ID. Returns undefined if expired or not found. */ get(sessionId: string): SubAgentSession | undefined { const session = this.sessions.get(sessionId); if (!session) return undefined;
// Check idle timeout
if (Date.now() - session.lastUsedAt > this.idleTimeoutMs) {
session.close();
this.sessions.delete(sessionId);
return undefined;
}
return session;
}
/** Check if a session exists and is alive. */ has(sessionId: string): boolean { return this.get(sessionId) !== undefined; }
/** Close and remove a specific session. */ remove(sessionId: string): boolean { const session = this.sessions.get(sessionId); if (!session) return false; session.close(); this.sessions.delete(sessionId); return true; }
/** Cancel a specific session without evicting unrelated sessions. */ cancel(sessionId: string, reason?: string): boolean { const session = this.sessions.get(sessionId); if (!session) return false; session.cancel(reason); return true; }
/** Close all sessions and stop the sweep timer. */ closeAll(): void { for (const session of this.sessions.values()) { session.close(); } this.sessions.clear(); this.stopSweep(); }
/** Number of active sessions. */ get size(): number { return this.sessions.size; }
/** List active session IDs and types. */ list(): Array<{ id: string; type: string; status: string; lastUsedAt: number }> { const result: Array<{ id: string; type: string; status: string; lastUsedAt: number }> = []; for (const session of this.sessions.values()) { result.push({ id: session.id, type: session.type, status: session.status, lastUsedAt: session.lastUsedAt }); } return result; }
/** Remove idle sessions that have exceeded the timeout. */ private sweepIdle(): void { const now = Date.now(); for (const [id, session] of this.sessions) { if (now - session.lastUsedAt > this.idleTimeoutMs) { session.close(); this.sessions.delete(id); } } }
/** Evict the oldest idle session. If all are running, evict the oldest overall. */ private evictOldest(): void { let oldestIdle: SubAgentSession | null = null; let oldestOverall: SubAgentSession | null = null;
for (const session of this.sessions.values()) {
if (session.status !== "running") {
if (!oldestIdle || session.lastUsedAt < oldestIdle.lastUsedAt) {
oldestIdle = session;
}
}
if (!oldestOverall || session.lastUsedAt < oldestOverall.lastUsedAt) {
oldestOverall = session;
}
}
const toEvict = oldestIdle || oldestOverall;
if (toEvict) {
toEvict.close();
this.sessions.delete(toEvict.id);
}
} }
/**
- Generate a session ID from a scope key and optional suffix.
- Format:
{type}:{scopeKey}:{timestamp}:{counter}*/ let sessionCounter = 0;
export function generateSessionId(type: string, scopeKey: string): string {
sessionCounter += 1;
return ${type}:${scopeKey}:${Date.now()}:${sessionCounter};
}
