UNPKG

@posthog/agent

Version:

TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog

426 lines (421 loc) 16.9 kB
import { POSTHOG_NOTIFICATIONS } from './acp-extensions.js'; import { createAcpConnection } from './adapters/claude/claude.js'; import { PostHogFileManager } from './file-manager.js'; import { GitManager } from './git-manager.js'; import { PostHogAPIClient } from './posthog-api.js'; import { PromptBuilder } from './prompt-builder.js'; import { SessionStore } from './session-store.js'; import { TaskManager } from './task-manager.js'; import { TemplateManager } from './template-manager.js'; import { Logger } from './utils/logger.js'; import { TASK_WORKFLOW } from './workflow/config.js'; export { PermissionMode } from './types.js'; class Agent { workingDirectory; taskManager; posthogAPI; fileManager; gitManager; templateManager; logger; acpConnection; promptBuilder; mcpServers; canUseTool; currentRunId; sessionStore; debug; constructor(config) { this.workingDirectory = config.workingDirectory || process.cwd(); this.canUseTool = config.canUseTool; this.debug = config.debug || false; // Build default PostHog MCP server configuration const posthogMcpUrl = config.posthogMcpUrl || process.env.POSTHOG_MCP_URL || "https://mcp.posthog.com/mcp"; // Add auth if API key provided const headers = {}; if (config.posthogApiKey) { headers.Authorization = `Bearer ${config.posthogApiKey}`; } const defaultMcpServers = { posthog: { type: "http", url: posthogMcpUrl, ...(Object.keys(headers).length > 0 ? { headers } : {}), }, }; // Merge default PostHog MCP with user-provided servers (user config takes precedence) this.mcpServers = { ...defaultMcpServers, ...config.mcpServers, }; this.logger = new Logger({ debug: this.debug, prefix: "[PostHog Agent]", onLog: config.onLog, }); this.taskManager = new TaskManager(); this.fileManager = new PostHogFileManager(this.workingDirectory, this.logger.child("FileManager")); this.gitManager = new GitManager({ repositoryPath: this.workingDirectory, logger: this.logger.child("GitManager"), }); this.templateManager = new TemplateManager(); if (config.posthogApiUrl && config.posthogApiKey && config.posthogProjectId) { this.posthogAPI = new PostHogAPIClient({ apiUrl: config.posthogApiUrl, apiKey: config.posthogApiKey, projectId: config.posthogProjectId, }); // Create SessionStore from the API client for ACP connection this.sessionStore = new SessionStore(this.posthogAPI, this.logger.child("SessionStore")); } this.promptBuilder = new PromptBuilder({ getTaskFiles: (taskId) => this.getTaskFiles(taskId), generatePlanTemplate: (vars) => this.templateManager.generatePlan(vars), posthogClient: this.posthogAPI, logger: this.logger.child("PromptBuilder"), }); } /** * Enable or disable debug logging */ setDebug(enabled) { this.debug = enabled; this.logger.setDebug(enabled); } /** * Configure LLM gateway environment variables for Claude Code CLI */ async _configureLlmGateway() { if (!this.posthogAPI) { return; } try { const gatewayUrl = this.posthogAPI.getLlmGatewayUrl(); const apiKey = this.posthogAPI.getApiKey(); process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = apiKey; this.ensureOpenAIGatewayEnv(gatewayUrl, apiKey); } catch (error) { this.logger.error("Failed to configure LLM gateway", error); throw error; } } getOrCreateConnection() { if (!this.acpConnection) { this.acpConnection = createAcpConnection({ sessionStore: this.sessionStore, }); } return this.acpConnection; } // Adaptive task execution orchestrated via workflow steps async runTask(taskId, taskRunId, options = {}) { // await this._configureLlmGateway(); const task = await this.fetchTask(taskId); const cwd = options.repositoryPath || this.workingDirectory; const isCloudMode = options.isCloudMode ?? false; const taskSlug = task.slug || task.id; // Use taskRunId as sessionId - they are the same identifier this.currentRunId = taskRunId; this.logger.info("Starting adaptive task execution", { taskId: task.id, taskSlug, taskRunId, isCloudMode, }); const connection = this.getOrCreateConnection(); // Create sendNotification using ACP connection's extNotification const sendNotification = async (method, params) => { this.logger.debug(`Notification: ${method}`, params); await connection.agentConnection.extNotification?.(method, params); }; await sendNotification(POSTHOG_NOTIFICATIONS.RUN_STARTED, { sessionId: taskRunId, runId: taskRunId, }); await this.prepareTaskBranch(taskSlug, isCloudMode, sendNotification); let taskError; try { const workflowContext = { task, taskSlug, runId: taskRunId, cwd, isCloudMode, options, logger: this.logger, fileManager: this.fileManager, gitManager: this.gitManager, promptBuilder: this.promptBuilder, connection: connection.agentConnection, sessionId: taskRunId, sendNotification, mcpServers: this.mcpServers, posthogAPI: this.posthogAPI, stepResults: {}, }; for (const step of TASK_WORKFLOW) { const result = await step.run({ step, context: workflowContext }); if (result.halt) { return; } } const shouldCreatePR = options.createPR ?? isCloudMode; if (shouldCreatePR) { await this.ensurePullRequest(task, workflowContext.stepResults, sendNotification); } this.logger.info("Task execution complete", { taskId: task.id }); await sendNotification(POSTHOG_NOTIFICATIONS.TASK_COMPLETE, { sessionId: taskRunId, taskId: task.id, }); } catch (error) { taskError = error instanceof Error ? error : new Error(String(error)); this.logger.error("Task execution failed", { taskId: task.id, error: taskError.message, }); await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { sessionId: taskRunId, message: taskError.message, }); throw taskError; } } /** * Creates an in-process ACP connection for client communication. * Sets up git branch for the task, configures LLM gateway. * The client handles all prompting/querying via the returned streams. * * @returns InProcessAcpConnection with clientStreams for the client to use */ async runTaskV2(taskId, taskRunId, options = {}) { await this._configureLlmGateway(); const task = await this.fetchTask(taskId); const taskSlug = task.slug || task.id; const isCloudMode = options.isCloudMode ?? false; options.repositoryPath || this.workingDirectory; // Use taskRunId as sessionId - they are the same identifier this.currentRunId = taskRunId; this.acpConnection = createAcpConnection({ sessionStore: this.sessionStore, sessionId: taskRunId, taskId: task.id, }); const sendNotification = async (method, params) => { this.logger.debug(`Notification: ${method}`, params); await this.acpConnection?.agentConnection.extNotification?.(method, params); }; await sendNotification(POSTHOG_NOTIFICATIONS.RUN_STARTED, { sessionId: taskRunId, runId: taskRunId, }); if (!options.skipGitBranch) { try { await this.prepareTaskBranch(taskSlug, isCloudMode, sendNotification); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error("Failed to prepare task branch", { error: errorMessage, }); await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { sessionId: taskRunId, message: errorMessage, }); throw error; } } return this.acpConnection; } // PostHog task operations async fetchTask(taskId) { if (!this.posthogAPI) { const error = new Error("PostHog API not configured. Provide posthogApiUrl and posthogApiKey in constructor."); this.logger.error("PostHog API not configured", error); throw error; } return this.posthogAPI.fetchTask(taskId); } getPostHogClient() { return this.posthogAPI; } async getTaskFiles(taskId) { this.logger.debug("Getting task files", { taskId }); const files = await this.fileManager.getTaskFiles(taskId); this.logger.debug("Found task files", { taskId, fileCount: files.length }); return files; } async createPullRequest(taskId, branchName, taskTitle, taskDescription, customBody) { this.logger.info("Creating pull request", { taskId, branchName, taskTitle, }); const defaultBody = `## Task Details **Task ID**: ${taskId} **Description**: ${taskDescription} ## Changes This PR implements the changes described in the task. Generated by PostHog Agent`; const prBody = customBody || defaultBody; const prUrl = await this.gitManager.createPullRequest(branchName, taskTitle, prBody); this.logger.info("Pull request created", { taskId, prUrl }); return prUrl; } async attachPullRequestToTask(taskId, prUrl, branchName) { this.logger.info("Attaching PR to task run", { taskId, prUrl, branchName }); if (!this.posthogAPI || !this.currentRunId) { const error = new Error("PostHog API not configured or no active run. Cannot attach PR to task."); this.logger.error("PostHog API not configured", error); throw error; } const updates = { output: { pr_url: prUrl }, }; if (branchName) { updates.branch = branchName; } await this.posthogAPI.updateTaskRun(taskId, this.currentRunId, updates); this.logger.debug("PR attached to task run", { taskId, runId: this.currentRunId, prUrl, }); } async updateTaskBranch(taskId, branchName) { this.logger.info("Updating task run branch", { taskId, branchName }); if (!this.posthogAPI || !this.currentRunId) { const error = new Error("PostHog API not configured or no active run. Cannot update branch."); this.logger.error("PostHog API not configured", error); throw error; } await this.posthogAPI.updateTaskRun(taskId, this.currentRunId, { branch: branchName, }); this.logger.debug("Task run branch updated", { taskId, runId: this.currentRunId, branchName, }); } // Execution management cancelTask(taskId) { // Find the execution for this task and cancel it for (const [executionId, execution] of this.taskManager.executionStates) { if (execution.taskId === taskId && execution.status === "running") { this.taskManager.cancelExecution(executionId); break; } } } getTaskExecutionStatus(taskId) { // Find the execution for this task for (const execution of this.taskManager.executionStates.values()) { if (execution.taskId === taskId) { return execution.status; } } return null; } async prepareTaskBranch(taskSlug, isCloudMode, sendNotification) { if (await this.gitManager.hasChanges()) { throw new Error("Cannot start task with uncommitted changes. Please commit or stash your changes first."); } // If we're running in a worktree, we're already on the correct branch // (the worktree was created with its own branch). Skip branch creation. const isWorktree = await this.gitManager.isWorktree(); if (isWorktree) { const currentBranch = await this.gitManager.getCurrentBranch(); this.logger.info("Running in worktree, using existing branch", { branch: currentBranch, }); await sendNotification(POSTHOG_NOTIFICATIONS.BRANCH_CREATED, { branch: currentBranch, }); return; } await this.gitManager.resetToDefaultBranchIfNeeded(); const existingBranch = await this.gitManager.getTaskBranch(taskSlug); if (!existingBranch) { const branchName = await this.gitManager.createTaskBranch(taskSlug); await sendNotification(POSTHOG_NOTIFICATIONS.BRANCH_CREATED, { branch: branchName, }); await this.gitManager.addAllPostHogFiles(); // Only commit if there are changes or we're in cloud mode if (isCloudMode) { await this.gitManager.commitAndPush(`Initialize task ${taskSlug}`, { allowEmpty: true, }); } else { // Check if there are any changes before committing const hasChanges = await this.gitManager.hasStagedChanges(); if (hasChanges) { await this.gitManager.commitChanges(`Initialize task ${taskSlug}`); } } } else { this.logger.info("Switching to existing task branch", { branch: existingBranch, }); await this.gitManager.switchToBranch(existingBranch); } } ensureOpenAIGatewayEnv(gatewayUrl, token) { const resolvedGatewayUrl = gatewayUrl || process.env.ANTHROPIC_BASE_URL; const resolvedToken = token || process.env.ANTHROPIC_AUTH_TOKEN; if (resolvedGatewayUrl) { process.env.OPENAI_BASE_URL = resolvedGatewayUrl; } if (resolvedToken) { process.env.OPENAI_API_KEY = resolvedToken; } } async ensurePullRequest(task, stepResults, sendNotification) { const latestRun = task.latest_run; const existingPr = latestRun?.output && typeof latestRun.output === "object" ? latestRun.output.pr_url : null; if (existingPr) { this.logger.info("PR already exists, skipping creation", { taskId: task.id, prUrl: existingPr, }); return; } const buildResult = stepResults.build; if (!buildResult?.commitCreated) { this.logger.warn("Build step did not produce a commit; skipping PR creation", { taskId: task.id }); return; } const branchName = await this.gitManager.getCurrentBranch(); const finalizeResult = stepResults.finalize; const prBody = finalizeResult?.prBody; const prUrl = await this.createPullRequest(task.id, branchName, task.title, task.description ?? "", prBody); await sendNotification(POSTHOG_NOTIFICATIONS.PR_CREATED, { prUrl }); try { await this.attachPullRequestToTask(task.id, prUrl, branchName); this.logger.info("PR attached to task successfully", { taskId: task.id, prUrl, }); } catch (error) { this.logger.warn("Could not attach PR to task", { error: error instanceof Error ? error.message : String(error), }); } } } export { Agent }; //# sourceMappingURL=agent.js.map