agent-session.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  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. appendTranscriptEvent,
  22. nextTranscriptSeq,
  23. readTranscriptEvents,
  24. } from "../interaction/session-transcript.js";
  25. import {
  26. TOOL_RESULT_BRIDGE_TEXT,
  27. adaptRestoredAgentMessagesForModel,
  28. restoreAgentMessagesFromTranscript,
  29. } from "../interaction/session-transcript-restore.js";
  30. import type { TranscriptRole } from "../interaction/session-transcript-schema.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 true; set INKOS_AGENT_ALLOW_SYSTEM_READ=0 to disable. */
  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. // We only record fields that can realistically change between turns on the
  66. // same sessionId and are captured into the Agent at construction time.
  67. // `projectRoot`, `language`, and `pipeline` are also closure-captured by the
  68. // Agent (into systemPrompt / tools / transformContext), but within a single
  69. // server process they're treated as stable — we don't re-check them.
  70. interface CachedAgent {
  71. agent: Agent;
  72. bookId: string | null;
  73. modelIdentity: string;
  74. allowSystemFileRead: boolean;
  75. lastCommittedSeq: number;
  76. lastActive: number;
  77. }
  78. const agentCache = new Map<string, CachedAgent>();
  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. async function latestCommittedSeq(projectRoot: string, sessionId: string): Promise<number> {
  135. const events = await readTranscriptEvents(projectRoot, sessionId);
  136. return events
  137. .filter((event) => event.type === "request_committed")
  138. .reduce((max, event) => Math.max(max, event.seq), 0);
  139. }
  140. function transcriptRoleForMessage(message: AgentMessage): TranscriptRole | null {
  141. if (!message || typeof message !== "object" || !("role" in message)) return null;
  142. const role = (message as { role?: unknown }).role;
  143. return role === "user" || role === "assistant" || role === "toolResult" || role === "system"
  144. ? role
  145. : null;
  146. }
  147. function firstToolCallId(message: AgentMessage): string | undefined {
  148. if (!message || typeof message !== "object" || !("content" in message)) return undefined;
  149. const content = (message as { content?: unknown }).content;
  150. if (!Array.isArray(content)) return undefined;
  151. const block = content.find(
  152. (item): item is { type: "toolCall"; id: string } =>
  153. !!item &&
  154. typeof item === "object" &&
  155. (item as { type?: unknown }).type === "toolCall" &&
  156. typeof (item as { id?: unknown }).id === "string",
  157. );
  158. return block?.id;
  159. }
  160. function toolCallIdForMessage(message: AgentMessage): string | undefined {
  161. if (!message || typeof message !== "object") return undefined;
  162. if ((message as { role?: unknown }).role === "toolResult") {
  163. const toolCallId = (message as { toolCallId?: unknown }).toolCallId;
  164. return typeof toolCallId === "string" && toolCallId.length > 0 ? toolCallId : undefined;
  165. }
  166. return firstToolCallId(message);
  167. }
  168. function messageTimestamp(message: AgentMessage): number {
  169. if (message && typeof message === "object") {
  170. const timestamp = (message as { timestamp?: unknown }).timestamp;
  171. if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp >= 0) {
  172. return Math.floor(timestamp);
  173. }
  174. }
  175. return Date.now();
  176. }
  177. async function ensureSessionCreatedEvent(
  178. projectRoot: string,
  179. sessionId: string,
  180. bookId: string | null,
  181. ): Promise<number> {
  182. const events = await readTranscriptEvents(projectRoot, sessionId);
  183. if (events.length > 0) return events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
  184. const now = Date.now();
  185. await appendTranscriptEvent(projectRoot, {
  186. type: "session_created",
  187. version: 1,
  188. sessionId,
  189. seq: 1,
  190. timestamp: now,
  191. bookId,
  192. title: null,
  193. createdAt: now,
  194. updatedAt: now,
  195. });
  196. return 2;
  197. }
  198. /**
  199. * Extract readable text from an AssistantMessage's content array.
  200. * Filters out tool-call blocks; concatenates text blocks.
  201. */
  202. function extractTextFromAssistant(msg: AssistantMessage): string {
  203. return msg.content
  204. .filter((c): c is { type: "text"; text: string } => c.type === "text")
  205. .map((c) => c.text)
  206. .join("");
  207. }
  208. function lastAssistantMessage(messages: AgentMessage[]): AssistantMessage | undefined {
  209. for (let i = messages.length - 1; i >= 0; i--) {
  210. const msg = messages[i];
  211. if (msg && typeof msg === "object" && "role" in msg && (msg as { role?: unknown }).role === "assistant") {
  212. return msg as AssistantMessage;
  213. }
  214. }
  215. return undefined;
  216. }
  217. function assistantErrorMessage(message: AssistantMessage | undefined): string | undefined {
  218. return message &&
  219. (message.stopReason === "error" || message.stopReason === "aborted") &&
  220. message.errorMessage
  221. ? message.errorMessage
  222. : undefined;
  223. }
  224. function convertAgentMessagesForModel(messages: AgentMessage[], model: Model<Api>): Message[] {
  225. const llmMessages = messages.filter((message): message is Message => {
  226. if (!message || typeof message !== "object" || !("role" in message)) return false;
  227. return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
  228. });
  229. const candidate = model as { api?: unknown; baseUrl?: unknown };
  230. const isGoogleOpenAICompatible = (
  231. candidate.api === "openai-completions" &&
  232. typeof candidate.baseUrl === "string" &&
  233. candidate.baseUrl.includes("generativelanguage.googleapis.com")
  234. );
  235. if (!isGoogleOpenAICompatible) return llmMessages;
  236. const converted: Message[] = [];
  237. const pushToolResultsAsUser = (toolResults: ToolResultMessage[]) => {
  238. const lines = toolResults.flatMap((result) => {
  239. const content = result.content
  240. .map((block) => block.type === "text" ? block.text : "[image]")
  241. .filter(Boolean)
  242. .join("\n")
  243. .trim() || "(empty tool result)";
  244. return [`- ${result.toolName} (${result.toolCallId}):`, content];
  245. });
  246. converted.push({
  247. role: "user",
  248. content: [
  249. "[Tool results]",
  250. ...lines,
  251. "Use these tool results to answer the active user request. If a tool failed, explain the failure and choose the next useful action.",
  252. ].join("\n"),
  253. timestamp: toolResults.reduce(
  254. (max, result) => Math.max(max, messageTimestamp(result as AgentMessage)),
  255. 0,
  256. ) || Date.now(),
  257. });
  258. };
  259. for (let i = 0; i < llmMessages.length; i++) {
  260. const message = llmMessages[i];
  261. if (message.role === "assistant") {
  262. const textContent = message.content.filter(
  263. (block): block is { type: "text"; text: string } =>
  264. block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0,
  265. );
  266. if (
  267. textContent.length === 1 &&
  268. message.content.length === 1 &&
  269. textContent[0].text.trim() === TOOL_RESULT_BRIDGE_TEXT
  270. ) {
  271. continue;
  272. }
  273. const toolCallIds = new Set<string>();
  274. for (const block of message.content) {
  275. if (block.type === "toolCall" && typeof block.id === "string" && block.id.length > 0) {
  276. toolCallIds.add(block.id);
  277. }
  278. }
  279. if (toolCallIds.size === 0) {
  280. converted.push(message);
  281. continue;
  282. }
  283. if (textContent.length > 0) {
  284. converted.push({ ...message, content: textContent });
  285. }
  286. const toolResults: ToolResultMessage[] = [];
  287. let nextIndex = i + 1;
  288. while (nextIndex < llmMessages.length) {
  289. const next = llmMessages[nextIndex];
  290. if (next.role !== "toolResult" || !toolCallIds.has(next.toolCallId)) break;
  291. toolResults.push(next);
  292. nextIndex += 1;
  293. }
  294. if (toolResults.length > 0) {
  295. pushToolResultsAsUser(toolResults);
  296. i = nextIndex - 1;
  297. }
  298. continue;
  299. }
  300. if (message.role === "toolResult") {
  301. pushToolResultsAsUser([message]);
  302. continue;
  303. }
  304. converted.push(message);
  305. }
  306. return converted;
  307. }
  308. /**
  309. * Extract thinking/reasoning text from an AssistantMessage's content array.
  310. */
  311. function extractThinkingFromAssistant(msg: AssistantMessage): string {
  312. return msg.content
  313. .filter((c: any) => c.type === "thinking")
  314. .map((c: any) => c.thinking ?? "")
  315. .join("");
  316. }
  317. /**
  318. * Convert plain `{ role, content }` messages (from BookSession disk storage)
  319. * back into pi-agent AgentMessage format so they can be loaded into an Agent.
  320. */
  321. function plainToAgentMessages(
  322. plain: Array<{ role: string; content: string }>,
  323. ): AgentMessage[] {
  324. return plain.map((m) => {
  325. const ts = Date.now();
  326. if (m.role === "user") {
  327. return { role: "user", content: m.content, timestamp: ts } satisfies UserMessage;
  328. }
  329. // For stored assistant messages we only have the text.
  330. // Re-wrap as a minimal AssistantMessage with a single TextContent.
  331. return {
  332. role: "assistant",
  333. content: [{ type: "text", text: m.content }],
  334. api: "anthropic-messages",
  335. provider: "anthropic",
  336. model: "unknown",
  337. usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
  338. stopReason: "stop",
  339. timestamp: ts,
  340. } satisfies AssistantMessage;
  341. });
  342. }
  343. /**
  344. * Flatten the Agent's in-memory messages to plain `{ role, content }` pairs
  345. * suitable for BookSession persistence.
  346. */
  347. function agentMessagesToPlain(
  348. messages: AgentMessage[],
  349. ): Array<{ role: string; content: string; thinking?: string }> {
  350. const out: Array<{ role: string; content: string; thinking?: string }> = [];
  351. for (const msg of messages) {
  352. if (!msg || typeof msg !== "object" || !("role" in msg)) continue;
  353. const m = msg as { role: string; [k: string]: any };
  354. if (m.role === "user") {
  355. const content = typeof m.content === "string"
  356. ? m.content
  357. : Array.isArray(m.content)
  358. ? m.content
  359. .filter((c: any) => c.type === "text")
  360. .map((c: any) => c.text)
  361. .join("")
  362. : "";
  363. if (content) out.push({ role: "user", content });
  364. } else if (m.role === "assistant") {
  365. const text = extractTextFromAssistant(m as AssistantMessage);
  366. const thinking = extractThinkingFromAssistant(m as AssistantMessage);
  367. if (text || thinking) {
  368. const entry: { role: string; content: string; thinking?: string } = { role: "assistant", content: text };
  369. if (thinking) entry.thinking = thinking;
  370. out.push(entry);
  371. }
  372. }
  373. // ToolResult messages are internal; skip them for persistence.
  374. }
  375. return out;
  376. }
  377. // ---------------------------------------------------------------------------
  378. // Main entry point
  379. // ---------------------------------------------------------------------------
  380. /**
  381. * Run a single conversation turn within a cached Agent session.
  382. *
  383. * If the session already exists in the cache, reuses the Agent (with its full
  384. * in-memory message history including tool calls). Otherwise creates a new
  385. * Agent, optionally restoring messages from `initialMessages`.
  386. */
  387. export async function runAgentSession(
  388. config: AgentSessionConfig,
  389. userMessage: string,
  390. initialMessages?: Array<{ role: string; content: string }>,
  391. ): Promise<AgentSessionResult> {
  392. const { sessionId, language, pipeline, projectRoot, onEvent } = config;
  393. // Normalize at the entry point so downstream comparisons, closures, and
  394. // fs paths never see `undefined`. The type is already `string | null`, but
  395. // some callers may bypass the type system (e.g. `activeBookId ?? null` gets
  396. // skipped) and we don't want that to (a) throw in path.join or (b) trigger
  397. // a spurious cache eviction because `null !== undefined`.
  398. const bookId: string | null = config.bookId ?? null;
  399. const model = resolveModel(config.model);
  400. const requestedModelIdentity = agentModelIdentity(model);
  401. const allowSystemFileRead = config.allowSystemFileRead ?? envFlagEnabled(process.env.INKOS_AGENT_ALLOW_SYSTEM_READ, true);
  402. // ----- Resolve or create Agent -----
  403. let cached = agentCache.get(sessionId);
  404. let currentCommittedSeq: number | undefined;
  405. if (cached) {
  406. currentCommittedSeq = await latestCommittedSeq(projectRoot, sessionId);
  407. // Evict and rebuild if model protocol identity OR bookId changed. Both are
  408. // captured into the Agent at construction time (model via initialState,
  409. // bookId via closures in systemPrompt / tools / transformContext), so a
  410. // mismatch means the cached Agent would keep using stale context.
  411. const modelChanged = cached.modelIdentity !== requestedModelIdentity;
  412. const bookChanged = cached.bookId !== bookId;
  413. const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
  414. const transcriptChanged = cached.lastCommittedSeq !== currentCommittedSeq;
  415. if (modelChanged || bookChanged || readPermissionChanged || transcriptChanged) {
  416. agentCache.delete(sessionId);
  417. cached = undefined;
  418. }
  419. }
  420. if (!cached) {
  421. const restoredMessages = adaptRestoredAgentMessagesForModel(
  422. await restoreAgentMessagesFromTranscript(projectRoot, sessionId),
  423. model,
  424. );
  425. const initialAgentMessages = restoredMessages.length > 0
  426. ? restoredMessages
  427. : initialMessages && initialMessages.length > 0
  428. ? plainToAgentMessages(initialMessages)
  429. : [];
  430. const agent = new Agent({
  431. initialState: {
  432. model,
  433. systemPrompt: buildAgentSystemPrompt(bookId, language),
  434. tools: [
  435. createSubAgentTool(pipeline, bookId, projectRoot),
  436. createReadTool(projectRoot, { allowSystemPaths: allowSystemFileRead }),
  437. createWriteTruthFileTool(pipeline, projectRoot, bookId),
  438. createRenameEntityTool(pipeline, projectRoot, bookId),
  439. createPatchChapterTextTool(pipeline, projectRoot, bookId),
  440. createEditTool(projectRoot),
  441. createWriteFileTool(projectRoot),
  442. createGrepTool(projectRoot),
  443. createLsTool(projectRoot),
  444. ],
  445. messages: initialAgentMessages,
  446. },
  447. transformContext: createBookContextTransform(bookId, projectRoot),
  448. convertToLlm: (messages) => convertAgentMessagesForModel(messages, model),
  449. streamFn: streamSimple,
  450. getApiKey: (provider: string) => {
  451. if (config.apiKey) return config.apiKey;
  452. return getEnvApiKey(provider);
  453. },
  454. });
  455. cached = {
  456. agent,
  457. bookId,
  458. modelIdentity: requestedModelIdentity,
  459. allowSystemFileRead,
  460. lastCommittedSeq: currentCommittedSeq ?? await latestCommittedSeq(projectRoot, sessionId),
  461. lastActive: Date.now(),
  462. };
  463. agentCache.set(sessionId, cached);
  464. ensureCleanupTimer();
  465. }
  466. cached.lastActive = Date.now();
  467. const { agent } = cached;
  468. // ----- Prepare transcript persistence -----
  469. const requestId = randomUUID();
  470. let seq = await ensureSessionCreatedEvent(projectRoot, sessionId, bookId);
  471. await appendTranscriptEvent(projectRoot, {
  472. type: "request_started",
  473. version: 1,
  474. sessionId,
  475. requestId,
  476. seq: seq++,
  477. timestamp: Date.now(),
  478. input: userMessage,
  479. });
  480. let parentUuid: string | null = null;
  481. let piTurnIndex = 0;
  482. let lastAssistantUuid: string | null = null;
  483. const persistAgentEvent = async (event: AgentEvent): Promise<void> => {
  484. if (event.type === "turn_start") {
  485. piTurnIndex += 1;
  486. return;
  487. }
  488. if (event.type !== "message_end") return;
  489. const role = transcriptRoleForMessage(event.message);
  490. if (!role) return;
  491. const uuid = randomUUID();
  492. const isToolResult = role === "toolResult";
  493. const toolCallId = toolCallIdForMessage(event.message);
  494. await appendTranscriptEvent(projectRoot, {
  495. type: "message",
  496. version: 1,
  497. sessionId,
  498. requestId,
  499. uuid,
  500. parentUuid: isToolResult && lastAssistantUuid ? lastAssistantUuid : parentUuid,
  501. seq: seq++,
  502. role,
  503. timestamp: messageTimestamp(event.message),
  504. piTurnIndex,
  505. ...(toolCallId ? { toolCallId } : {}),
  506. ...(isToolResult && lastAssistantUuid
  507. ? { sourceToolAssistantUuid: lastAssistantUuid }
  508. : {}),
  509. message: event.message,
  510. });
  511. if (role === "assistant") lastAssistantUuid = uuid;
  512. parentUuid = uuid;
  513. };
  514. // ----- Subscribe to events (transcript persistence + SSE forwarding) -----
  515. const unsubscribe = agent.subscribe(async (event: AgentEvent) => {
  516. await persistAgentEvent(event);
  517. onEvent?.(event);
  518. });
  519. // ----- Execute the turn -----
  520. let finalAssistant: AssistantMessage | undefined;
  521. let errorMessage: string | undefined;
  522. try {
  523. await agent.prompt(userMessage);
  524. finalAssistant = lastAssistantMessage(agent.state.messages);
  525. errorMessage = assistantErrorMessage(finalAssistant);
  526. if (errorMessage) {
  527. await appendTranscriptEvent(projectRoot, {
  528. type: "request_failed",
  529. version: 1,
  530. sessionId,
  531. requestId,
  532. seq: seq++,
  533. timestamp: Date.now(),
  534. error: errorMessage,
  535. });
  536. agentCache.delete(sessionId);
  537. } else {
  538. await appendTranscriptEvent(projectRoot, {
  539. type: "request_committed",
  540. version: 1,
  541. sessionId,
  542. requestId,
  543. seq: seq++,
  544. timestamp: Date.now(),
  545. });
  546. cached.lastCommittedSeq = seq - 1;
  547. }
  548. } catch (error) {
  549. await appendTranscriptEvent(projectRoot, {
  550. type: "request_failed",
  551. version: 1,
  552. sessionId,
  553. requestId,
  554. seq: seq++,
  555. timestamp: Date.now(),
  556. error: error instanceof Error ? error.message : String(error),
  557. });
  558. throw error;
  559. } finally {
  560. unsubscribe();
  561. }
  562. // ----- Extract result -----
  563. const allMessages = agent.state.messages;
  564. finalAssistant ??= lastAssistantMessage(allMessages);
  565. const responseText = finalAssistant ? extractTextFromAssistant(finalAssistant) : "";
  566. errorMessage ??= assistantErrorMessage(finalAssistant);
  567. return {
  568. responseText,
  569. messages: allMessages.slice(),
  570. ...(errorMessage ? { errorMessage } : {}),
  571. };
  572. }
  573. // ---------------------------------------------------------------------------
  574. // Cache management
  575. // ---------------------------------------------------------------------------
  576. /** Manually evict a cached Agent session. */
  577. export function evictAgentCache(sessionId: string): boolean {
  578. return agentCache.delete(sessionId);
  579. }