| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304 |
- import { Agent } from "@mariozechner/pi-agent-core";
- import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
- import { streamSimple, getModel, getEnvApiKey } from "@mariozechner/pi-ai";
- import type { Model, Api, AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
- import type { PipelineRunner } from "../pipeline/runner.js";
- import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
- import {
- createSubAgentTool,
- createReadTool,
- createEditTool,
- createGrepTool,
- createLsTool,
- } from "./agent-tools.js";
- // ---------------------------------------------------------------------------
- // Types
- // ---------------------------------------------------------------------------
- export interface AgentSessionConfig {
- /** Unique session identifier (typically the BookSession id). */
- sessionId: string;
- /** Book ID, or null if in "new book" mode. */
- bookId: string | null;
- /** Language for the system prompt. */
- language: string;
- /** PipelineRunner for sub-agent tool delegation. */
- pipeline: PipelineRunner;
- /** Project root directory (books/ lives under this). */
- projectRoot: string;
- /** pi-ai Model to use, or provider+modelId to resolve via getModel. */
- model: Model<Api> | { provider: string; modelId: string };
- /** Optional API key. When omitted, falls back to env-based key lookup. */
- apiKey?: string;
- /** Optional listener for streaming events (for SSE forwarding). */
- onEvent?: (event: AgentEvent) => void;
- }
- export interface AgentSessionResult {
- /** Extracted text from the final assistant message. */
- responseText: string;
- /** Full conversation history for persistence. */
- messages: Array<{ role: string; content: string; thinking?: string }>;
- }
- // ---------------------------------------------------------------------------
- // Cache
- // ---------------------------------------------------------------------------
- interface CachedAgent {
- agent: Agent;
- lastActive: number;
- }
- const agentCache = new Map<string, CachedAgent>();
- /** TTL for cached agents: 5 minutes. */
- const CACHE_TTL_MS = 5 * 60 * 1000;
- /** Cleanup interval handle (lazy-started). */
- let cleanupTimer: ReturnType<typeof setInterval> | null = null;
- function ensureCleanupTimer(): void {
- if (cleanupTimer) return;
- cleanupTimer = setInterval(() => {
- const now = Date.now();
- for (const [id, entry] of agentCache) {
- if (now - entry.lastActive > CACHE_TTL_MS) {
- agentCache.delete(id);
- }
- }
- // Stop the timer when nothing left to watch.
- if (agentCache.size === 0 && cleanupTimer) {
- clearInterval(cleanupTimer);
- cleanupTimer = null;
- }
- }, 60_000); // run every 60 s
- // Allow the process to exit even if this timer is alive.
- if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
- cleanupTimer.unref();
- }
- }
- // ---------------------------------------------------------------------------
- // Helpers
- // ---------------------------------------------------------------------------
- function resolveModel(spec: AgentSessionConfig["model"]): Model<Api> {
- if (!spec) {
- throw new Error("Model is required but was undefined. Check LLM configuration.");
- }
- if (typeof spec === "object" && "id" in spec && "api" in spec) {
- // Already a Model object.
- return spec as Model<Api>;
- }
- const { provider, modelId } = spec as { provider: string; modelId: string };
- if (!provider || !modelId) {
- throw new Error(`Invalid model spec: provider=${provider}, modelId=${modelId}`);
- }
- return getModel(provider as any, modelId as any);
- }
- /**
- * Extract readable text from an AssistantMessage's content array.
- * Filters out tool-call blocks; concatenates text blocks.
- */
- function extractTextFromAssistant(msg: AssistantMessage): string {
- return msg.content
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
- .map((c) => c.text)
- .join("");
- }
- /**
- * Extract thinking/reasoning text from an AssistantMessage's content array.
- */
- function extractThinkingFromAssistant(msg: AssistantMessage): string {
- return msg.content
- .filter((c: any) => c.type === "thinking")
- .map((c: any) => c.thinking ?? "")
- .join("");
- }
- /**
- * Convert plain `{ role, content }` messages (from BookSession disk storage)
- * back into pi-agent AgentMessage format so they can be loaded into an Agent.
- */
- function plainToAgentMessages(
- plain: Array<{ role: string; content: string }>,
- ): AgentMessage[] {
- return plain.map((m) => {
- const ts = Date.now();
- if (m.role === "user") {
- return { role: "user", content: m.content, timestamp: ts } satisfies UserMessage;
- }
- // For stored assistant messages we only have the text.
- // Re-wrap as a minimal AssistantMessage with a single TextContent.
- return {
- role: "assistant",
- content: [{ type: "text", text: m.content }],
- api: "anthropic-messages",
- provider: "anthropic",
- model: "unknown",
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
- stopReason: "stop",
- timestamp: ts,
- } satisfies AssistantMessage;
- });
- }
- /**
- * Flatten the Agent's in-memory messages to plain `{ role, content }` pairs
- * suitable for BookSession persistence.
- */
- function agentMessagesToPlain(
- messages: AgentMessage[],
- ): Array<{ role: string; content: string; thinking?: string }> {
- const out: Array<{ role: string; content: string; thinking?: string }> = [];
- for (const msg of messages) {
- if (!msg || typeof msg !== "object" || !("role" in msg)) continue;
- const m = msg as { role: string; [k: string]: any };
- if (m.role === "user") {
- const content = typeof m.content === "string"
- ? m.content
- : Array.isArray(m.content)
- ? m.content
- .filter((c: any) => c.type === "text")
- .map((c: any) => c.text)
- .join("")
- : "";
- if (content) out.push({ role: "user", content });
- } else if (m.role === "assistant") {
- const text = extractTextFromAssistant(m as AssistantMessage);
- const thinking = extractThinkingFromAssistant(m as AssistantMessage);
- if (text || thinking) {
- const entry: { role: string; content: string; thinking?: string } = { role: "assistant", content: text };
- if (thinking) entry.thinking = thinking;
- out.push(entry);
- }
- }
- // ToolResult messages are internal; skip them for persistence.
- }
- return out;
- }
- // ---------------------------------------------------------------------------
- // Main entry point
- // ---------------------------------------------------------------------------
- /**
- * Run a single conversation turn within a cached Agent session.
- *
- * If the session already exists in the cache, reuses the Agent (with its full
- * in-memory message history including tool calls). Otherwise creates a new
- * Agent, optionally restoring messages from `initialMessages`.
- */
- export async function runAgentSession(
- config: AgentSessionConfig,
- userMessage: string,
- initialMessages?: Array<{ role: string; content: string }>,
- ): Promise<AgentSessionResult> {
- const { sessionId, bookId, language, pipeline, projectRoot, onEvent } = config;
- // ----- Resolve or create Agent -----
- let cached = agentCache.get(sessionId);
- if (cached) {
- // Check if model changed — evict and rebuild if so
- const currentModelId = (cached.agent.state.model as any)?.id;
- const newModelId = typeof config.model === 'object' && 'id' in config.model
- ? (config.model as any).id
- : undefined;
- if (currentModelId && newModelId && currentModelId !== newModelId) {
- // Preserve conversation messages for re-injection
- const preservedMessages = agentMessagesToPlain(cached.agent.state.messages);
- agentCache.delete(sessionId);
- cached = undefined;
- // Pass preserved messages as initialMessages if none were provided
- if (!initialMessages || initialMessages.length === 0) {
- initialMessages = preservedMessages;
- }
- }
- }
- if (!cached) {
- const model = resolveModel(config.model);
- const agent = new Agent({
- initialState: {
- model,
- systemPrompt: buildAgentSystemPrompt(bookId, language),
- tools: [
- createSubAgentTool(pipeline, bookId),
- createReadTool(projectRoot),
- createEditTool(projectRoot),
- createGrepTool(projectRoot),
- createLsTool(projectRoot),
- ],
- },
- streamFn: streamSimple,
- getApiKey: (provider: string) => {
- if (config.apiKey) return config.apiKey;
- return getEnvApiKey(provider);
- },
- });
- // Restore prior conversation if provided.
- if (initialMessages && initialMessages.length > 0) {
- agent.state.messages = plainToAgentMessages(initialMessages);
- }
- cached = { agent, lastActive: Date.now() };
- agentCache.set(sessionId, cached);
- ensureCleanupTimer();
- }
- cached.lastActive = Date.now();
- const { agent } = cached;
- // ----- Subscribe to events (for SSE streaming to frontend) -----
- let unsubscribe: (() => void) | undefined;
- if (onEvent) {
- unsubscribe = agent.subscribe((event: AgentEvent) => {
- onEvent(event);
- });
- }
- // ----- Execute the turn -----
- try {
- await agent.prompt(userMessage);
- } finally {
- unsubscribe?.();
- }
- // ----- Extract result -----
- const allMessages = agent.state.messages;
- const responseText = extractResponseText(allMessages);
- const plainMessages = agentMessagesToPlain(allMessages);
- return { responseText, messages: plainMessages };
- }
- /**
- * Walk backward through messages to find the last assistant message and
- * extract its text content.
- */
- function extractResponseText(messages: AgentMessage[]): string {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (msg && typeof msg === "object" && "role" in msg && (msg as any).role === "assistant") {
- return extractTextFromAssistant(msg as AssistantMessage);
- }
- }
- return "";
- }
- // ---------------------------------------------------------------------------
- // Cache management
- // ---------------------------------------------------------------------------
- /** Manually evict a cached Agent session. */
- export function evictAgentCache(sessionId: string): boolean {
- return agentCache.delete(sessionId);
- }
|