Files
AgentCompany/src/main/session-management-service.ts
T
ZacharyZhang-NYandClaude Opus 4.6 e54c632fd6 feat(06-01): add session management service with rotation, thresholds, and delta tracking
- Create createSessionManagementService factory with accumulateTokenUsage,
  evaluateRotation (configurable thresholds per D-01), generateHandoff,
  rotateSession, getHandoffContext, snapshotUsage, getUsageDelta
- Export DEFAULT_THRESHOLDS (maxRuns: 20, maxTokens: 500k, maxAgeHours: 72) and RotationThresholds type
- Adapter nativeContextManagement exemption skips rotation for Claude/Codex (D-04)
- Handoff markdown capped at 2000 chars with structured sections (D-06)
- Add 14 unit tests for service: rotation thresholds, adapter exemption, configurable
  thresholds, token accumulation, handoff generation, session rotation, usage delta

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:56:18 -04:00

208 lines
7.6 KiB
TypeScript

import type { AppDatabase } from "./database";
import { getConnectorDefinition } from "./connectors";
import type { ConnectorId, TaskSessionRecord } from "@shared/types";
interface SessionManagementDependencies {
db: AppDatabase;
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
}
// D-01/D-02: Exported for testing and external override. Conservative defaults.
export const DEFAULT_THRESHOLDS = {
maxRuns: 20,
maxTokens: 500_000,
maxAgeHours: 72,
};
// D-01: Type for per-adapter or company-level threshold overrides
export type RotationThresholds = Partial<typeof DEFAULT_THRESHOLDS>;
// D-04: Handoff size cap to prevent prompt bloat
const MAX_HANDOFF_LENGTH = 2000;
const MAX_HANDOFF_RUNS = 5;
export function createSessionManagementService({ db, logger }: SessionManagementDependencies) {
// D-08: Accumulate token usage on task_sessions row (called for ALL adapters per D-05)
function accumulateTokenUsage(sessionId: string, inputTokens: number, outputTokens: number, runId: string): void {
db.incrementSessionUsage(sessionId, inputTokens, outputTokens, runId);
}
// D-01: Any-of thresholds with optional overrides. D-04: Check nativeContextManagement.
// Per D-01: "Configurable per-adapter with company-level overrides" -- accepts optional
// thresholds parameter that merges with defaults. Callers can pass per-adapter or
// company-level overrides; omitted fields fall back to DEFAULT_THRESHOLDS.
function evaluateRotation(
session: TaskSessionRecord,
connectorId: string,
thresholdsOverride?: RotationThresholds,
): { shouldRotate: boolean; reason: string | null } {
// D-04: Check adapter exemption
try {
const connector = getConnectorDefinition(connectorId as ConnectorId);
if (connector.module.nativeContextManagement === true) {
return { shouldRotate: false, reason: null };
}
} catch {
// Unknown connector - proceed with rotation check
}
// Merge overrides with defaults (per D-01 configurability)
const thresholds = { ...DEFAULT_THRESHOLDS, ...thresholdsOverride };
// D-01: Any-of thresholds - first trigger wins
if (session.runCount >= thresholds.maxRuns) {
return { shouldRotate: true, reason: "max_runs" };
}
const totalTokens = session.totalInputTokens + session.totalOutputTokens;
if (totalTokens >= thresholds.maxTokens) {
return { shouldRotate: true, reason: "max_tokens" };
}
const ageMs = Date.now() - new Date(session.createdAt).getTime();
const ageHours = ageMs / (1000 * 60 * 60);
if (ageHours >= thresholds.maxAgeHours) {
return { shouldRotate: true, reason: "max_age_hours" };
}
return { shouldRotate: false, reason: null };
}
// D-06: Build handoff markdown from structured data, no AI call
function generateHandoff(taskId: string, session: TaskSessionRecord): string {
let task: { title: string; description: string } | null = null;
try {
task = db.getTask(taskId);
} catch {
task = null;
}
const recentRuns = db.getRecentRunsForTask(taskId, MAX_HANDOFF_RUNS);
const errorRuns = recentRuns.filter(r => r.errorMessage);
const activityEntries = db.getRecentActivityForTask(taskId, 10);
const decisionEntries = activityEntries.filter(a =>
a.action.includes("decision") || a.action.includes("approval") || a.action.includes("resolved")
);
const sections: string[] = [];
sections.push("# Session Handoff");
sections.push("");
if (task) {
sections.push("## Task");
sections.push(`**${task.title}**`);
const descSnippet = task.description.length > 200 ? task.description.slice(0, 200) + "..." : task.description;
sections.push(descSnippet);
sections.push("");
}
if (recentRuns.length > 0) {
sections.push("## Recent Run History");
for (const run of recentRuns) {
const summaryText = run.summary ? run.summary.slice(0, 100) : "(no summary)";
sections.push(`- [${run.status}] ${summaryText} (${run.finishedAt ?? "in progress"})`);
}
sections.push("");
}
if (errorRuns.length > 0) {
sections.push("## Errors Encountered");
for (const run of errorRuns) {
sections.push(`- ${run.errorMessage!.slice(0, 150)} (${run.finishedAt ?? "unknown"})`);
}
sections.push("");
}
if (decisionEntries.length > 0) {
sections.push("## Key Decisions");
for (const entry of decisionEntries.slice(0, 5)) {
sections.push(`- ${entry.detail.slice(0, 120)} (${entry.createdAt})`);
}
sections.push("");
}
sections.push(`*Session stats: ${session.runCount} runs, ${session.totalInputTokens + session.totalOutputTokens} total tokens*`);
let result = sections.join("\n");
// Cap handoff size per research pitfall #5
if (result.length > MAX_HANDOFF_LENGTH) {
result = result.slice(0, MAX_HANDOFF_LENGTH - 50) + "\n\n... (earlier content omitted)";
}
return result;
}
// Archive current session, generate handoff, create new session, update task pointer
function rotateSession(session: TaskSessionRecord): string {
const reason = "threshold_exceeded";
const handoff = generateHandoff(session.taskId, session);
logger.info(`[session] Rotating session ${session.id} for task ${session.taskId}: ${reason}`);
// 1. Archive old session
db.archiveSession(session.id, reason, handoff);
// 2. Create new session row with fresh counters, carry forward session_params
const newSessionId = db.createNewSession({
taskId: session.taskId,
companyId: session.companyId,
agentId: session.agentId,
adapterType: session.adapterType,
sessionParamsJson: session.sessionParamsJson,
sessionDisplayId: session.sessionDisplayId,
});
logger.info(`[session] New session ${newSessionId} created for task ${session.taskId}`);
return newSessionId;
}
// D-07: Read handoff from most recently rotated session for a task
function getHandoffContext(taskId: string): string | null {
// Find the most recent archived session for this task (the one just before current)
const row = db.queryAll<{ handoffMarkdown: string | null }>(
`SELECT handoff_markdown as handoffMarkdown FROM task_sessions
WHERE task_id = ? AND rotated_at IS NOT NULL
ORDER BY rotated_at DESC LIMIT 1`,
taskId
);
return row[0]?.handoffMarkdown ?? null;
}
// D-09: Snapshot token totals for delta tracking
function snapshotUsage(session: TaskSessionRecord): { inputTokens: number; outputTokens: number } {
return {
inputTokens: session.totalInputTokens,
outputTokens: session.totalOutputTokens,
};
}
// D-09: Compute delta from snapshot
function getUsageDelta(
sessionId: string,
snapshot: { inputTokens: number; outputTokens: number }
): { inputTokensDelta: number; outputTokensDelta: number } {
// Re-read current totals from DB
const current = db.queryAll<{ totalInputTokens: number; totalOutputTokens: number }>(
`SELECT total_input_tokens as totalInputTokens, total_output_tokens as totalOutputTokens
FROM task_sessions WHERE id = ?`,
sessionId
);
if (!current[0]) return { inputTokensDelta: 0, outputTokensDelta: 0 };
return {
inputTokensDelta: current[0].totalInputTokens - snapshot.inputTokens,
outputTokensDelta: current[0].totalOutputTokens - snapshot.outputTokens,
};
}
return {
accumulateTokenUsage,
evaluateRotation,
generateHandoff,
rotateSession,
getHandoffContext,
snapshotUsage,
getUsageDelta,
};
}