agent-session.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735
  1. import { randomUUID } from "node:crypto";
  2. import { Agent } from "@mariozechner/pi-agent-core";
  3. import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
  4. import { streamSimple, getModel, getEnvApiKey } from "@mariozechner/pi-ai";
  5. import type { Model, Api, AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
  6. import type { PipelineRunner } from "../pipeline/runner.js";
  7. import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
  8. import {
  9. createPatchChapterTextTool,
  10. createRenameEntityTool,
  11. createSubAgentTool,
  12. createReadTool,
  13. createEditTool,
  14. createWriteFileTool,
  15. createGrepTool,
  16. createLsTool,
  17. createWriteTruthFileTool,
  18. } from "./agent-tools.js";
  19. import { createBookContextTransform } from "./context-transform.js";
  20. import {
  21. appendTranscriptEvents,
  22. readTranscriptEvents,
  23. } from "../interaction/session-transcript.js";
  24. import {
  25. TOOL_RESULT_BRIDGE_TEXT,
  26. adaptRestoredAgentMessagesForModel,
  27. restoreAgentMessagesFromTranscript,
  28. } from "../interaction/session-transcript-restore.js";
  29. import type { TranscriptEvent, TranscriptRole } from "../interaction/session-transcript-schema.js";
  30. import { assertSafeBookId } from "../utils/book-id.js";
  31. // ---------------------------------------------------------------------------
  32. // Types
  33. // ---------------------------------------------------------------------------
  34. export interface AgentSessionConfig {
  35. /** Unique session identifier (typically the BookSession id). */
  36. sessionId: string;
  37. /** Book ID, or null if in "new book" mode. */
  38. bookId: string | null;
  39. /** Language for the system prompt. */
  40. language: string;
  41. /** PipelineRunner for sub-agent tool delegation. */
  42. pipeline: PipelineRunner;
  43. /** Project root directory (books/ lives under this). */
  44. projectRoot: string;
  45. /** pi-ai Model to use, or provider+modelId to resolve via getModel. */
  46. model: Model<Api> | { provider: string; modelId: string };
  47. /** Optional API key. When omitted, falls back to env-based key lookup. */
  48. apiKey?: string;
  49. /** Allow the read tool to read absolute paths outside projectRoot/books. Defaults to false; set INKOS_AGENT_ALLOW_SYSTEM_READ=1 to enable. */
  50. allowSystemFileRead?: boolean;
  51. /** Optional listener for streaming events (for SSE forwarding). */
  52. onEvent?: (event: AgentEvent) => void;
  53. }
  54. export interface AgentSessionResult {
  55. /** Extracted text from the final assistant message. */
  56. responseText: string;
  57. /** Full raw Agent conversation history. */
  58. messages: AgentMessage[];
  59. /** Upstream model error surfaced by pi-agent-core, if the final assistant turn failed. */
  60. errorMessage?: string;
  61. }
  62. // ---------------------------------------------------------------------------
  63. // Cache
  64. // ---------------------------------------------------------------------------
  65. interface CachedAgent {
  66. agent: Agent;
  67. sessionId: string;
  68. projectRoot: string;
  69. bookId: string | null;
  70. language: string;
  71. modelIdentity: string;
  72. apiKey: string | undefined;
  73. allowSystemFileRead: boolean;
  74. lastCommittedSeq: number;
  75. lastActive: number;
  76. }
  77. const agentCache = new Map<string, CachedAgent>();
  78. const agentSessionQueues = new Map<string, Promise<void>>();
  79. /** TTL for cached agents: 5 minutes. */
  80. const CACHE_TTL_MS = 5 * 60 * 1000;
  81. /** Cleanup interval handle (lazy-started). */
  82. let cleanupTimer: ReturnType<typeof setInterval> | null = null;
  83. function ensureCleanupTimer(): void {
  84. if (cleanupTimer) return;
  85. cleanupTimer = setInterval(() => {
  86. const now = Date.now();
  87. for (const [id, entry] of agentCache) {
  88. if (now - entry.lastActive > CACHE_TTL_MS) {
  89. agentCache.delete(id);
  90. }
  91. }
  92. // Stop the timer when nothing left to watch.
  93. if (agentCache.size === 0 && cleanupTimer) {
  94. clearInterval(cleanupTimer);
  95. cleanupTimer = null;
  96. }
  97. }, 60_000); // run every 60 s
  98. // Allow the process to exit even if this timer is alive.
  99. if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
  100. cleanupTimer.unref();
  101. }
  102. }
  103. // ---------------------------------------------------------------------------
  104. // Helpers
  105. // ---------------------------------------------------------------------------
  106. function resolveModel(spec: AgentSessionConfig["model"]): Model<Api> {
  107. if (!spec) {
  108. throw new Error("Model is required but was undefined. Check LLM configuration.");
  109. }
  110. if (typeof spec === "object" && "id" in spec && "api" in spec) {
  111. // Already a Model object.
  112. return spec as Model<Api>;
  113. }
  114. const { provider, modelId } = spec as { provider: string; modelId: string };
  115. if (!provider || !modelId) {
  116. throw new Error(`Invalid model spec: provider=${provider}, modelId=${modelId}`);
  117. }
  118. return getModel(provider as any, modelId as any);
  119. }
  120. function envFlagEnabled(value: string | undefined, defaultValue: boolean): boolean {
  121. if (value === undefined) return defaultValue;
  122. if (value === "1" || value.toLowerCase() === "true") return true;
  123. if (value === "0" || value.toLowerCase() === "false") return false;
  124. return defaultValue;
  125. }
  126. function agentModelIdentity(model: Model<Api>): string {
  127. return [
  128. model.api,
  129. model.provider,
  130. model.baseUrl ?? "",
  131. model.id,
  132. ].join("::");
  133. }
  134. function sessionQueueKey(projectRoot: string, sessionId: string): string {
  135. return `${projectRoot}\0${sessionId}`;
  136. }
  137. function agentCacheKey(projectRoot: string, sessionId: string): string {
  138. return sessionQueueKey(projectRoot, sessionId);
  139. }
  140. async function runInAgentSessionQueue<T>(
  141. projectRoot: string,
  142. sessionId: string,
  143. task: () => Promise<T>,
  144. ): Promise<T> {
  145. const key = sessionQueueKey(projectRoot, sessionId);
  146. const previous = agentSessionQueues.get(key) ?? Promise.resolve();
  147. let release!: () => void;
  148. const gate = new Promise<void>((resolve) => {
  149. release = resolve;
  150. });
  151. const queued = previous.catch(() => undefined).then(() => gate);
  152. agentSessionQueues.set(key, queued);
  153. await previous.catch(() => undefined);
  154. try {
  155. return await task();
  156. } finally {
  157. release();
  158. if (agentSessionQueues.get(key) === queued) {
  159. agentSessionQueues.delete(key);
  160. }
  161. }
  162. }
  163. async function latestCommittedSeq(projectRoot: string, sessionId: string): Promise<number> {
  164. const events = await readTranscriptEvents(projectRoot, sessionId);
  165. return events
  166. .filter((event) => event.type === "request_committed")
  167. .reduce((max, event) => Math.max(max, event.seq), 0);
  168. }
  169. function transcriptRoleForMessage(message: AgentMessage): TranscriptRole | null {
  170. if (!message || typeof message !== "object" || !("role" in message)) return null;
  171. const role = (message as { role?: unknown }).role;
  172. return role === "user" || role === "assistant" || role === "toolResult" || role === "system"
  173. ? role
  174. : null;
  175. }
  176. function firstToolCallId(message: AgentMessage): string | undefined {
  177. if (!message || typeof message !== "object" || !("content" in message)) return undefined;
  178. const content = (message as { content?: unknown }).content;
  179. if (!Array.isArray(content)) return undefined;
  180. const block = content.find(
  181. (item): item is { type: "toolCall"; id: string } =>
  182. !!item &&
  183. typeof item === "object" &&
  184. (item as { type?: unknown }).type === "toolCall" &&
  185. typeof (item as { id?: unknown }).id === "string",
  186. );
  187. return block?.id;
  188. }
  189. function toolCallIdForMessage(message: AgentMessage): string | undefined {
  190. if (!message || typeof message !== "object") return undefined;
  191. if ((message as { role?: unknown }).role === "toolResult") {
  192. const toolCallId = (message as { toolCallId?: unknown }).toolCallId;
  193. return typeof toolCallId === "string" && toolCallId.length > 0 ? toolCallId : undefined;
  194. }
  195. return firstToolCallId(message);
  196. }
  197. function messageTimestamp(message: AgentMessage): number {
  198. if (message && typeof message === "object") {
  199. const timestamp = (message as { timestamp?: unknown }).timestamp;
  200. if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp >= 0) {
  201. return Math.floor(timestamp);
  202. }
  203. }
  204. return Date.now();
  205. }
  206. async function ensureSessionCreatedEvent(
  207. projectRoot: string,
  208. sessionId: string,
  209. bookId: string | null,
  210. ): Promise<void> {
  211. await appendTranscriptEvents(projectRoot, sessionId, ({ events, nextSeq }) => {
  212. if (events.some((event) => event.type === "session_created")) return [];
  213. const now = Date.now();
  214. return [{
  215. type: "session_created",
  216. version: 1,
  217. sessionId,
  218. seq: nextSeq,
  219. timestamp: now,
  220. bookId,
  221. title: null,
  222. createdAt: now,
  223. updatedAt: now,
  224. }];
  225. });
  226. }
  227. async function appendAgentTranscriptEvent(
  228. projectRoot: string,
  229. sessionId: string,
  230. buildEvent: (seq: number) => TranscriptEvent,
  231. ): Promise<TranscriptEvent> {
  232. const events = await appendTranscriptEvents(projectRoot, sessionId, ({ nextSeq }) => [
  233. buildEvent(nextSeq),
  234. ]);
  235. const event = events[0];
  236. if (!event) throw new Error(`Failed to append transcript event for session "${sessionId}"`);
  237. return event;
  238. }
  239. /**
  240. * Extract readable text from an AssistantMessage's content array.
  241. * Filters out tool-call blocks; concatenates text blocks.
  242. */
  243. function extractTextFromAssistant(msg: AssistantMessage): string {
  244. return msg.content
  245. .filter((c): c is { type: "text"; text: string } => c.type === "text")
  246. .map((c) => c.text)
  247. .join("");
  248. }
  249. function lastAssistantMessage(messages: AgentMessage[]): AssistantMessage | undefined {
  250. for (let i = messages.length - 1; i >= 0; i--) {
  251. const msg = messages[i];
  252. if (msg && typeof msg === "object" && "role" in msg && (msg as { role?: unknown }).role === "assistant") {
  253. return msg as AssistantMessage;
  254. }
  255. }
  256. return undefined;
  257. }
  258. function assistantErrorMessage(message: AssistantMessage | undefined): string | undefined {
  259. return message &&
  260. (message.stopReason === "error" || message.stopReason === "aborted") &&
  261. message.errorMessage
  262. ? message.errorMessage
  263. : undefined;
  264. }
  265. function convertAgentMessagesForModel(messages: AgentMessage[], model: Model<Api>): Message[] {
  266. const llmMessages = messages.filter((message): message is Message => {
  267. if (!message || typeof message !== "object" || !("role" in message)) return false;
  268. return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
  269. });
  270. const candidate = model as { api?: unknown; baseUrl?: unknown };
  271. const isGoogleOpenAICompatible = (
  272. candidate.api === "openai-completions" &&
  273. typeof candidate.baseUrl === "string" &&
  274. candidate.baseUrl.includes("generativelanguage.googleapis.com")
  275. );
  276. if (!isGoogleOpenAICompatible) return llmMessages;
  277. const converted: Message[] = [];
  278. const pushToolResultsAsUser = (toolResults: ToolResultMessage[]) => {
  279. const lines = toolResults.flatMap((result) => {
  280. const content = result.content
  281. .map((block) => block.type === "text" ? block.text : "[image]")
  282. .filter(Boolean)
  283. .join("\n")
  284. .trim() || "(empty tool result)";
  285. return [`- ${result.toolName} (${result.toolCallId}):`, content];
  286. });
  287. converted.push({
  288. role: "user",
  289. content: [
  290. "[Tool results]",
  291. ...lines,
  292. "Use these tool results to answer the active user request. If a tool failed, explain the failure and choose the next useful action.",
  293. ].join("\n"),
  294. timestamp: toolResults.reduce(
  295. (max, result) => Math.max(max, messageTimestamp(result as AgentMessage)),
  296. 0,
  297. ) || Date.now(),
  298. });
  299. };
  300. for (let i = 0; i < llmMessages.length; i++) {
  301. const message = llmMessages[i];
  302. if (message.role === "assistant") {
  303. const textContent = message.content.filter(
  304. (block): block is { type: "text"; text: string } =>
  305. block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0,
  306. );
  307. if (
  308. textContent.length === 1 &&
  309. message.content.length === 1 &&
  310. textContent[0].text.trim() === TOOL_RESULT_BRIDGE_TEXT
  311. ) {
  312. continue;
  313. }
  314. const toolCallIds = new Set<string>();
  315. for (const block of message.content) {
  316. if (block.type === "toolCall" && typeof block.id === "string" && block.id.length > 0) {
  317. toolCallIds.add(block.id);
  318. }
  319. }
  320. if (toolCallIds.size === 0) {
  321. converted.push(message);
  322. continue;
  323. }
  324. if (textContent.length > 0) {
  325. converted.push({ ...message, content: textContent });
  326. }
  327. const toolResults: ToolResultMessage[] = [];
  328. let nextIndex = i + 1;
  329. while (nextIndex < llmMessages.length) {
  330. const next = llmMessages[nextIndex];
  331. if (next.role !== "toolResult" || !toolCallIds.has(next.toolCallId)) break;
  332. toolResults.push(next);
  333. nextIndex += 1;
  334. }
  335. if (toolResults.length > 0) {
  336. pushToolResultsAsUser(toolResults);
  337. i = nextIndex - 1;
  338. }
  339. continue;
  340. }
  341. if (message.role === "toolResult") {
  342. pushToolResultsAsUser([message]);
  343. continue;
  344. }
  345. converted.push(message);
  346. }
  347. return converted;
  348. }
  349. /**
  350. * Extract thinking/reasoning text from an AssistantMessage's content array.
  351. */
  352. function extractThinkingFromAssistant(msg: AssistantMessage): string {
  353. return msg.content
  354. .filter((c: any) => c.type === "thinking")
  355. .map((c: any) => c.thinking ?? "")
  356. .join("");
  357. }
  358. /**
  359. * Convert plain `{ role, content }` messages (from BookSession disk storage)
  360. * back into pi-agent AgentMessage format so they can be loaded into an Agent.
  361. */
  362. function plainToAgentMessages(
  363. plain: Array<{ role: string; content: string }>,
  364. ): AgentMessage[] {
  365. return plain.map((m) => {
  366. const ts = Date.now();
  367. if (m.role === "user") {
  368. return { role: "user", content: m.content, timestamp: ts } satisfies UserMessage;
  369. }
  370. // For stored assistant messages we only have the text.
  371. // Re-wrap as a minimal AssistantMessage with a single TextContent.
  372. return {
  373. role: "assistant",
  374. content: [{ type: "text", text: m.content }],
  375. api: "anthropic-messages",
  376. provider: "anthropic",
  377. model: "unknown",
  378. usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
  379. stopReason: "stop",
  380. timestamp: ts,
  381. } satisfies AssistantMessage;
  382. });
  383. }
  384. /**
  385. * Flatten the Agent's in-memory messages to plain `{ role, content }` pairs
  386. * suitable for BookSession persistence.
  387. */
  388. function agentMessagesToPlain(
  389. messages: AgentMessage[],
  390. ): Array<{ role: string; content: string; thinking?: string }> {
  391. const out: Array<{ role: string; content: string; thinking?: string }> = [];
  392. for (const msg of messages) {
  393. if (!msg || typeof msg !== "object" || !("role" in msg)) continue;
  394. const m = msg as { role: string; [k: string]: any };
  395. if (m.role === "user") {
  396. const content = typeof m.content === "string"
  397. ? m.content
  398. : Array.isArray(m.content)
  399. ? m.content
  400. .filter((c: any) => c.type === "text")
  401. .map((c: any) => c.text)
  402. .join("")
  403. : "";
  404. if (content) out.push({ role: "user", content });
  405. } else if (m.role === "assistant") {
  406. const text = extractTextFromAssistant(m as AssistantMessage);
  407. const thinking = extractThinkingFromAssistant(m as AssistantMessage);
  408. if (text || thinking) {
  409. const entry: { role: string; content: string; thinking?: string } = { role: "assistant", content: text };
  410. if (thinking) entry.thinking = thinking;
  411. out.push(entry);
  412. }
  413. }
  414. // ToolResult messages are internal; skip them for persistence.
  415. }
  416. return out;
  417. }
  418. // ---------------------------------------------------------------------------
  419. // Main entry point
  420. // ---------------------------------------------------------------------------
  421. function createAgentToolsForMode(params: {
  422. readonly pipeline: PipelineRunner;
  423. readonly bookId: string | null;
  424. readonly projectRoot: string;
  425. readonly allowSystemFileRead: boolean;
  426. }) {
  427. const subAgentTool = createSubAgentTool(params.pipeline, params.bookId, params.projectRoot);
  428. if (!params.bookId) {
  429. return [subAgentTool];
  430. }
  431. return [
  432. subAgentTool,
  433. createReadTool(params.projectRoot, { allowSystemPaths: params.allowSystemFileRead }),
  434. createWriteTruthFileTool(params.pipeline, params.projectRoot, params.bookId),
  435. createRenameEntityTool(params.pipeline, params.projectRoot, params.bookId),
  436. createPatchChapterTextTool(params.pipeline, params.projectRoot, params.bookId),
  437. createEditTool(params.projectRoot),
  438. createWriteFileTool(params.projectRoot),
  439. createGrepTool(params.projectRoot),
  440. createLsTool(params.projectRoot),
  441. ];
  442. }
  443. /**
  444. * Run a single conversation turn within a cached Agent session.
  445. *
  446. * If the session already exists in the cache, reuses the Agent (with its full
  447. * in-memory message history including tool calls). Otherwise creates a new
  448. * Agent, optionally restoring messages from `initialMessages`.
  449. */
  450. export async function runAgentSession(
  451. config: AgentSessionConfig,
  452. userMessage: string,
  453. initialMessages?: Array<{ role: string; content: string }>,
  454. ): Promise<AgentSessionResult> {
  455. return runInAgentSessionQueue(config.projectRoot, config.sessionId, () =>
  456. runAgentSessionUnlocked(config, userMessage, initialMessages)
  457. );
  458. }
  459. async function runAgentSessionUnlocked(
  460. config: AgentSessionConfig,
  461. userMessage: string,
  462. initialMessages?: Array<{ role: string; content: string }>,
  463. ): Promise<AgentSessionResult> {
  464. const { sessionId, language, pipeline, projectRoot, onEvent } = config;
  465. // Normalize at the entry point so downstream comparisons, closures, and
  466. // fs paths never see `undefined`. The type is already `string | null`, but
  467. // some callers may bypass the type system (e.g. `activeBookId ?? null` gets
  468. // skipped) and we don't want that to (a) throw in path.join or (b) trigger
  469. // a spurious cache eviction because `null !== undefined`.
  470. const bookId: string | null = config.bookId ? assertSafeBookId(config.bookId) : null;
  471. const model = resolveModel(config.model);
  472. const requestedModelIdentity = agentModelIdentity(model);
  473. const allowSystemFileRead = config.allowSystemFileRead ?? envFlagEnabled(process.env.INKOS_AGENT_ALLOW_SYSTEM_READ, false);
  474. const cacheKey = agentCacheKey(projectRoot, sessionId);
  475. // ----- Resolve or create Agent -----
  476. let cached = agentCache.get(cacheKey);
  477. let currentCommittedSeq: number | undefined;
  478. if (cached) {
  479. currentCommittedSeq = await latestCommittedSeq(projectRoot, sessionId);
  480. // Evict and rebuild if model protocol identity OR bookId changed. Both are
  481. // captured into the Agent at construction time (model via initialState,
  482. // bookId via closures in systemPrompt / tools / transformContext), so a
  483. // mismatch means the cached Agent would keep using stale context.
  484. const modelChanged = cached.modelIdentity !== requestedModelIdentity;
  485. const projectRootChanged = cached.projectRoot !== projectRoot;
  486. const bookChanged = cached.bookId !== bookId;
  487. const languageChanged = cached.language !== language;
  488. const apiKeyChanged = cached.apiKey !== config.apiKey;
  489. const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
  490. const transcriptChanged = cached.lastCommittedSeq !== currentCommittedSeq;
  491. if (
  492. modelChanged ||
  493. projectRootChanged ||
  494. bookChanged ||
  495. languageChanged ||
  496. apiKeyChanged ||
  497. readPermissionChanged ||
  498. transcriptChanged
  499. ) {
  500. agentCache.delete(cacheKey);
  501. cached = undefined;
  502. }
  503. }
  504. if (!cached) {
  505. const restoredMessages = adaptRestoredAgentMessagesForModel(
  506. await restoreAgentMessagesFromTranscript(projectRoot, sessionId),
  507. model,
  508. );
  509. const initialAgentMessages = restoredMessages.length > 0
  510. ? restoredMessages
  511. : initialMessages && initialMessages.length > 0
  512. ? plainToAgentMessages(initialMessages)
  513. : [];
  514. const agent = new Agent({
  515. initialState: {
  516. model,
  517. systemPrompt: buildAgentSystemPrompt(bookId, language),
  518. tools: createAgentToolsForMode({ pipeline, bookId, projectRoot, allowSystemFileRead }),
  519. messages: initialAgentMessages,
  520. },
  521. transformContext: createBookContextTransform(bookId, projectRoot),
  522. convertToLlm: (messages) => convertAgentMessagesForModel(messages, model),
  523. streamFn: streamSimple,
  524. getApiKey: (provider: string) => {
  525. if (config.apiKey) return config.apiKey;
  526. return getEnvApiKey(provider);
  527. },
  528. });
  529. cached = {
  530. agent,
  531. sessionId,
  532. projectRoot,
  533. bookId,
  534. language,
  535. modelIdentity: requestedModelIdentity,
  536. apiKey: config.apiKey,
  537. allowSystemFileRead,
  538. lastCommittedSeq: currentCommittedSeq ?? await latestCommittedSeq(projectRoot, sessionId),
  539. lastActive: Date.now(),
  540. };
  541. agentCache.set(cacheKey, cached);
  542. ensureCleanupTimer();
  543. }
  544. cached.lastActive = Date.now();
  545. const { agent } = cached;
  546. // ----- Prepare transcript persistence -----
  547. const requestId = randomUUID();
  548. await ensureSessionCreatedEvent(projectRoot, sessionId, bookId);
  549. await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
  550. type: "request_started",
  551. version: 1,
  552. sessionId,
  553. requestId,
  554. seq,
  555. timestamp: Date.now(),
  556. input: userMessage,
  557. }));
  558. let parentUuid: string | null = null;
  559. let piTurnIndex = 0;
  560. let lastAssistantUuid: string | null = null;
  561. const persistAgentEvent = async (event: AgentEvent): Promise<void> => {
  562. if (event.type === "turn_start") {
  563. piTurnIndex += 1;
  564. return;
  565. }
  566. if (event.type !== "message_end") return;
  567. const role = transcriptRoleForMessage(event.message);
  568. if (!role) return;
  569. const uuid = randomUUID();
  570. const isToolResult = role === "toolResult";
  571. const toolCallId = toolCallIdForMessage(event.message);
  572. await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
  573. type: "message",
  574. version: 1,
  575. sessionId,
  576. requestId,
  577. uuid,
  578. parentUuid: isToolResult && lastAssistantUuid ? lastAssistantUuid : parentUuid,
  579. seq,
  580. role,
  581. timestamp: messageTimestamp(event.message),
  582. piTurnIndex,
  583. ...(toolCallId ? { toolCallId } : {}),
  584. ...(isToolResult && lastAssistantUuid
  585. ? { sourceToolAssistantUuid: lastAssistantUuid }
  586. : {}),
  587. message: event.message,
  588. }));
  589. if (role === "assistant") lastAssistantUuid = uuid;
  590. parentUuid = uuid;
  591. };
  592. // ----- Subscribe to events (transcript persistence + SSE forwarding) -----
  593. const unsubscribe = agent.subscribe(async (event: AgentEvent) => {
  594. await persistAgentEvent(event);
  595. onEvent?.(event);
  596. });
  597. // ----- Execute the turn -----
  598. let finalAssistant: AssistantMessage | undefined;
  599. let errorMessage: string | undefined;
  600. try {
  601. await agent.prompt(userMessage);
  602. finalAssistant = lastAssistantMessage(agent.state.messages);
  603. errorMessage = assistantErrorMessage(finalAssistant);
  604. if (errorMessage) {
  605. const failedError = errorMessage;
  606. await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
  607. type: "request_failed",
  608. version: 1,
  609. sessionId,
  610. requestId,
  611. seq,
  612. timestamp: Date.now(),
  613. error: failedError,
  614. }));
  615. agentCache.delete(cacheKey);
  616. } else {
  617. const committed = await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
  618. type: "request_committed",
  619. version: 1,
  620. sessionId,
  621. requestId,
  622. seq,
  623. timestamp: Date.now(),
  624. }));
  625. cached.lastCommittedSeq = committed.seq;
  626. }
  627. } catch (error) {
  628. await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
  629. type: "request_failed",
  630. version: 1,
  631. sessionId,
  632. requestId,
  633. seq,
  634. timestamp: Date.now(),
  635. error: error instanceof Error ? error.message : String(error),
  636. }));
  637. agentCache.delete(cacheKey);
  638. throw error;
  639. } finally {
  640. unsubscribe();
  641. }
  642. // ----- Extract result -----
  643. const allMessages = agent.state.messages;
  644. finalAssistant ??= lastAssistantMessage(allMessages);
  645. const responseText = finalAssistant ? extractTextFromAssistant(finalAssistant) : "";
  646. errorMessage ??= assistantErrorMessage(finalAssistant);
  647. return {
  648. responseText,
  649. messages: allMessages.slice(),
  650. ...(errorMessage ? { errorMessage } : {}),
  651. };
  652. }
  653. // ---------------------------------------------------------------------------
  654. // Cache management
  655. // ---------------------------------------------------------------------------
  656. /** Manually evict a cached Agent session. */
  657. export function evictAgentCache(sessionId: string): boolean {
  658. let deleted = agentCache.delete(sessionId);
  659. for (const [key, entry] of agentCache) {
  660. if (entry.sessionId !== sessionId) continue;
  661. agentCache.delete(key);
  662. deleted = true;
  663. }
  664. return deleted;
  665. }