agent-session.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import { Agent } from "@mariozechner/pi-agent-core";
  2. import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
  3. import { streamSimple, getModel, getEnvApiKey } from "@mariozechner/pi-ai";
  4. import type { Model, Api, AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
  5. import type { PipelineRunner } from "../pipeline/runner.js";
  6. import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
  7. import {
  8. createSubAgentTool,
  9. createReadTool,
  10. createEditTool,
  11. createGrepTool,
  12. createLsTool,
  13. } from "./agent-tools.js";
  14. // ---------------------------------------------------------------------------
  15. // Types
  16. // ---------------------------------------------------------------------------
  17. export interface AgentSessionConfig {
  18. /** Unique session identifier (typically the BookSession id). */
  19. sessionId: string;
  20. /** Book ID, or null if in "new book" mode. */
  21. bookId: string | null;
  22. /** Language for the system prompt. */
  23. language: string;
  24. /** PipelineRunner for sub-agent tool delegation. */
  25. pipeline: PipelineRunner;
  26. /** Project root directory (books/ lives under this). */
  27. projectRoot: string;
  28. /** pi-ai Model to use, or provider+modelId to resolve via getModel. */
  29. model: Model<Api> | { provider: string; modelId: string };
  30. /** Optional API key. When omitted, falls back to env-based key lookup. */
  31. apiKey?: string;
  32. /** Optional listener for streaming events (for SSE forwarding). */
  33. onEvent?: (event: AgentEvent) => void;
  34. }
  35. export interface AgentSessionResult {
  36. /** Extracted text from the final assistant message. */
  37. responseText: string;
  38. /** Full conversation history for persistence. */
  39. messages: Array<{ role: string; content: string; thinking?: string }>;
  40. }
  41. // ---------------------------------------------------------------------------
  42. // Cache
  43. // ---------------------------------------------------------------------------
  44. interface CachedAgent {
  45. agent: Agent;
  46. lastActive: number;
  47. }
  48. const agentCache = new Map<string, CachedAgent>();
  49. /** TTL for cached agents: 5 minutes. */
  50. const CACHE_TTL_MS = 5 * 60 * 1000;
  51. /** Cleanup interval handle (lazy-started). */
  52. let cleanupTimer: ReturnType<typeof setInterval> | null = null;
  53. function ensureCleanupTimer(): void {
  54. if (cleanupTimer) return;
  55. cleanupTimer = setInterval(() => {
  56. const now = Date.now();
  57. for (const [id, entry] of agentCache) {
  58. if (now - entry.lastActive > CACHE_TTL_MS) {
  59. agentCache.delete(id);
  60. }
  61. }
  62. // Stop the timer when nothing left to watch.
  63. if (agentCache.size === 0 && cleanupTimer) {
  64. clearInterval(cleanupTimer);
  65. cleanupTimer = null;
  66. }
  67. }, 60_000); // run every 60 s
  68. // Allow the process to exit even if this timer is alive.
  69. if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
  70. cleanupTimer.unref();
  71. }
  72. }
  73. // ---------------------------------------------------------------------------
  74. // Helpers
  75. // ---------------------------------------------------------------------------
  76. function resolveModel(spec: AgentSessionConfig["model"]): Model<Api> {
  77. if (!spec) {
  78. throw new Error("Model is required but was undefined. Check LLM configuration.");
  79. }
  80. if (typeof spec === "object" && "id" in spec && "api" in spec) {
  81. // Already a Model object.
  82. return spec as Model<Api>;
  83. }
  84. const { provider, modelId } = spec as { provider: string; modelId: string };
  85. if (!provider || !modelId) {
  86. throw new Error(`Invalid model spec: provider=${provider}, modelId=${modelId}`);
  87. }
  88. return getModel(provider as any, modelId as any);
  89. }
  90. /**
  91. * Extract readable text from an AssistantMessage's content array.
  92. * Filters out tool-call blocks; concatenates text blocks.
  93. */
  94. function extractTextFromAssistant(msg: AssistantMessage): string {
  95. return msg.content
  96. .filter((c): c is { type: "text"; text: string } => c.type === "text")
  97. .map((c) => c.text)
  98. .join("");
  99. }
  100. /**
  101. * Extract thinking/reasoning text from an AssistantMessage's content array.
  102. */
  103. function extractThinkingFromAssistant(msg: AssistantMessage): string {
  104. return msg.content
  105. .filter((c: any) => c.type === "thinking")
  106. .map((c: any) => c.thinking ?? "")
  107. .join("");
  108. }
  109. /**
  110. * Convert plain `{ role, content }` messages (from BookSession disk storage)
  111. * back into pi-agent AgentMessage format so they can be loaded into an Agent.
  112. */
  113. function plainToAgentMessages(
  114. plain: Array<{ role: string; content: string }>,
  115. ): AgentMessage[] {
  116. return plain.map((m) => {
  117. const ts = Date.now();
  118. if (m.role === "user") {
  119. return { role: "user", content: m.content, timestamp: ts } satisfies UserMessage;
  120. }
  121. // For stored assistant messages we only have the text.
  122. // Re-wrap as a minimal AssistantMessage with a single TextContent.
  123. return {
  124. role: "assistant",
  125. content: [{ type: "text", text: m.content }],
  126. api: "anthropic-messages",
  127. provider: "anthropic",
  128. model: "unknown",
  129. usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
  130. stopReason: "stop",
  131. timestamp: ts,
  132. } satisfies AssistantMessage;
  133. });
  134. }
  135. /**
  136. * Flatten the Agent's in-memory messages to plain `{ role, content }` pairs
  137. * suitable for BookSession persistence.
  138. */
  139. function agentMessagesToPlain(
  140. messages: AgentMessage[],
  141. ): Array<{ role: string; content: string; thinking?: string }> {
  142. const out: Array<{ role: string; content: string; thinking?: string }> = [];
  143. for (const msg of messages) {
  144. if (!msg || typeof msg !== "object" || !("role" in msg)) continue;
  145. const m = msg as { role: string; [k: string]: any };
  146. if (m.role === "user") {
  147. const content = typeof m.content === "string"
  148. ? m.content
  149. : Array.isArray(m.content)
  150. ? m.content
  151. .filter((c: any) => c.type === "text")
  152. .map((c: any) => c.text)
  153. .join("")
  154. : "";
  155. if (content) out.push({ role: "user", content });
  156. } else if (m.role === "assistant") {
  157. const text = extractTextFromAssistant(m as AssistantMessage);
  158. const thinking = extractThinkingFromAssistant(m as AssistantMessage);
  159. if (text || thinking) {
  160. const entry: { role: string; content: string; thinking?: string } = { role: "assistant", content: text };
  161. if (thinking) entry.thinking = thinking;
  162. out.push(entry);
  163. }
  164. }
  165. // ToolResult messages are internal; skip them for persistence.
  166. }
  167. return out;
  168. }
  169. // ---------------------------------------------------------------------------
  170. // Main entry point
  171. // ---------------------------------------------------------------------------
  172. /**
  173. * Run a single conversation turn within a cached Agent session.
  174. *
  175. * If the session already exists in the cache, reuses the Agent (with its full
  176. * in-memory message history including tool calls). Otherwise creates a new
  177. * Agent, optionally restoring messages from `initialMessages`.
  178. */
  179. export async function runAgentSession(
  180. config: AgentSessionConfig,
  181. userMessage: string,
  182. initialMessages?: Array<{ role: string; content: string }>,
  183. ): Promise<AgentSessionResult> {
  184. const { sessionId, bookId, language, pipeline, projectRoot, onEvent } = config;
  185. // ----- Resolve or create Agent -----
  186. let cached = agentCache.get(sessionId);
  187. if (cached) {
  188. // Check if model changed — evict and rebuild if so
  189. const currentModelId = (cached.agent.state.model as any)?.id;
  190. const newModelId = typeof config.model === 'object' && 'id' in config.model
  191. ? (config.model as any).id
  192. : undefined;
  193. if (currentModelId && newModelId && currentModelId !== newModelId) {
  194. // Preserve conversation messages for re-injection
  195. const preservedMessages = agentMessagesToPlain(cached.agent.state.messages);
  196. agentCache.delete(sessionId);
  197. cached = undefined;
  198. // Pass preserved messages as initialMessages if none were provided
  199. if (!initialMessages || initialMessages.length === 0) {
  200. initialMessages = preservedMessages;
  201. }
  202. }
  203. }
  204. if (!cached) {
  205. const model = resolveModel(config.model);
  206. const agent = new Agent({
  207. initialState: {
  208. model,
  209. systemPrompt: buildAgentSystemPrompt(bookId, language),
  210. tools: [
  211. createSubAgentTool(pipeline, bookId),
  212. createReadTool(projectRoot),
  213. createEditTool(projectRoot),
  214. createGrepTool(projectRoot),
  215. createLsTool(projectRoot),
  216. ],
  217. },
  218. streamFn: streamSimple,
  219. getApiKey: (provider: string) => {
  220. if (config.apiKey) return config.apiKey;
  221. return getEnvApiKey(provider);
  222. },
  223. });
  224. // Restore prior conversation if provided.
  225. if (initialMessages && initialMessages.length > 0) {
  226. agent.state.messages = plainToAgentMessages(initialMessages);
  227. }
  228. cached = { agent, lastActive: Date.now() };
  229. agentCache.set(sessionId, cached);
  230. ensureCleanupTimer();
  231. }
  232. cached.lastActive = Date.now();
  233. const { agent } = cached;
  234. // ----- Subscribe to events (for SSE streaming to frontend) -----
  235. let unsubscribe: (() => void) | undefined;
  236. if (onEvent) {
  237. unsubscribe = agent.subscribe((event: AgentEvent) => {
  238. onEvent(event);
  239. });
  240. }
  241. // ----- Execute the turn -----
  242. try {
  243. await agent.prompt(userMessage);
  244. } finally {
  245. unsubscribe?.();
  246. }
  247. // ----- Extract result -----
  248. const allMessages = agent.state.messages;
  249. const responseText = extractResponseText(allMessages);
  250. const plainMessages = agentMessagesToPlain(allMessages);
  251. return { responseText, messages: plainMessages };
  252. }
  253. /**
  254. * Walk backward through messages to find the last assistant message and
  255. * extract its text content.
  256. */
  257. function extractResponseText(messages: AgentMessage[]): string {
  258. for (let i = messages.length - 1; i >= 0; i--) {
  259. const msg = messages[i];
  260. if (msg && typeof msg === "object" && "role" in msg && (msg as any).role === "assistant") {
  261. return extractTextFromAssistant(msg as AssistantMessage);
  262. }
  263. }
  264. return "";
  265. }
  266. // ---------------------------------------------------------------------------
  267. // Cache management
  268. // ---------------------------------------------------------------------------
  269. /** Manually evict a cached Agent session. */
  270. export function evictAgentCache(sessionId: string): boolean {
  271. return agentCache.delete(sessionId);
  272. }