diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa1ec1e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.tgz diff --git a/code-review-bot/app/api/code-review/route.ts b/code-review-bot/app/api/code-review/route.ts index ca45b85..5c96b87 100644 --- a/code-review-bot/app/api/code-review/route.ts +++ b/code-review-bot/app/api/code-review/route.ts @@ -24,6 +24,7 @@ const REQUEST_TIMEOUT_MS = 285_000; export const POST = chatKitEndpoint({ client: tilde, webhookSigningKey: env.TILDE_WEBHOOK_SIGNING_KEY, + requestTimeoutMs: REQUEST_TIMEOUT_MS, async handler(request, context) { const github = context.github; if (!github) { @@ -32,10 +33,7 @@ export const POST = chatKitEndpoint({ if (!github.owner || !github.repo || !github.pull_number) { throw new Error("The GitHub message must identify a pull request."); } - const signal = AbortSignal.any([ - request.signal, - AbortSignal.timeout(REQUEST_TIMEOUT_MS), - ]); + const signal = request.signal; const history = await context.session.history(); const messages = await convertToAiSdkMessages({ messages: [...history.items, ...context.messages], diff --git a/code-review-bot/package.json b/code-review-bot/package.json index 6cb1f92..7669bf8 100644 --- a/code-review-bot/package.json +++ b/code-review-bot/package.json @@ -32,7 +32,7 @@ "@ai-sdk/mcp": "1.0.59", "@ai-sdk/openai": "3.0.81", "@trytilde/harness-sdk": "0.1.2", - "@trytilde/harness-sdk-vercel-ai-node": "0.1.2", + "@trytilde/harness-sdk-vercel-ai-node": "0.2.0", "ai": "6.0.220", "modal": "0.9.0", "next": "16.2.12", diff --git a/code-review-bot/pnpm-lock.yaml b/code-review-bot/pnpm-lock.yaml index 17df6bb..c6c09d2 100644 --- a/code-review-bot/pnpm-lock.yaml +++ b/code-review-bot/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: 0.1.2 version: 0.1.2 '@trytilde/harness-sdk-vercel-ai-node': - specifier: 0.1.2 - version: 0.1.2(@ai-sdk/mcp@1.0.59(zod@4.4.3))(@trytilde/harness-sdk@0.1.2)(ai@6.0.220(zod@4.4.3)) + specifier: 0.2.0 + version: 0.2.0(@ai-sdk/mcp@1.0.59(zod@4.4.3))(@trytilde/harness-sdk@0.1.2)(ai@6.0.220(zod@4.4.3)) ai: specifier: 6.0.220 version: 6.0.220(zod@4.4.3) @@ -576,8 +576,8 @@ packages: '@trytilde/api-client@0.1.2': resolution: {integrity: sha512-kpwS1CgRqVrTrcYJ5ZrZm+iA1gamx5CO9YRzTT9rEdn5bIOTsrbHyTCvbBTH9XrBD3Q5K3dFenW0H2Ku7NMScA==} - '@trytilde/harness-sdk-vercel-ai-node@0.1.2': - resolution: {integrity: sha512-gk/1KH6Rb6kPUpUhtvkJqB5rHz5eBu6WDewcs0FbWJIZZ2A+uf0kr11p0WqklVgbrjn7tJjNZN6Un/Gg01lC2w==} + '@trytilde/harness-sdk-vercel-ai-node@0.2.0': + resolution: {integrity: sha512-JTCVeI9ZtRzM25VU+/Zyg4dQSp1aUUwqaKuRnw0QJC3DuZ52r9c7Lkw6lkkj50/ksSeaUWCkRyL2xqzpkGKwPg==} peerDependencies: '@ai-sdk/mcp': ^1.0.46 '@trytilde/harness-sdk': ^0.1.2 @@ -2525,7 +2525,7 @@ snapshots: '@trytilde/api-client@0.1.2': {} - '@trytilde/harness-sdk-vercel-ai-node@0.1.2(@ai-sdk/mcp@1.0.59(zod@4.4.3))(@trytilde/harness-sdk@0.1.2)(ai@6.0.220(zod@4.4.3))': + '@trytilde/harness-sdk-vercel-ai-node@0.2.0(@ai-sdk/mcp@1.0.59(zod@4.4.3))(@trytilde/harness-sdk@0.1.2)(ai@6.0.220(zod@4.4.3))': dependencies: '@ai-sdk/mcp': 1.0.59(zod@4.4.3) '@trytilde/harness-sdk': 0.1.2 diff --git a/sentry-remediation-bot/app/api/sentry-remediation/route.ts b/sentry-remediation-bot/app/api/sentry-remediation/route.ts index f82aa0f..dced313 100644 --- a/sentry-remediation-bot/app/api/sentry-remediation/route.ts +++ b/sentry-remediation-bot/app/api/sentry-remediation/route.ts @@ -1,6 +1,15 @@ -import { chatKitEndpoint, convertToAiSdkMessages, createMCPClient } from "@trytilde/harness-sdk-vercel-ai-node"; +import { + chatKitEndpoint, + convertToAiSdkMessages, + createMCPClient, +} from "@trytilde/harness-sdk-vercel-ai-node"; import { openai } from "@ai-sdk/openai"; -import { consumeStream, convertToModelMessages, stepCountIs, streamText } from "ai"; +import { + consumeStream, + convertToModelMessages, + stepCountIs, + streamText, +} from "ai"; import { env } from "@/lib/env"; import { remediationPrompt } from "@/lib/remediation/prompt"; import { @@ -8,7 +17,7 @@ import { createRemediationSandbox, type RemediationSandbox, } from "@/lib/remediation/sandbox"; -import { latestSentrySignalPrompt } from "@/lib/remediation/signal"; +import { sentryIssueCreatedMessage } from "@/lib/remediation/signal"; import { tilde } from "@/lib/tilde"; export const maxDuration = 300; @@ -17,63 +26,79 @@ const REQUEST_TIMEOUT_MS = 285_000; export const POST = chatKitEndpoint({ client: tilde, webhookSigningKey: env.TILDE_WEBHOOK_SIGNING_KEY, + requestTimeoutMs: REQUEST_TIMEOUT_MS, async handler(request, context) { - const signal = AbortSignal.any([request.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]); - const messages = await convertToAiSdkMessages({ messages: context.messages, chatkit: context.chatkit }); - const signalPrompt = await waitForSentrySignalPrompt(context.sessionId, signal); - const { mcp, closeMcp } = await createMCPClient({ client: tilde, serverId: env.TILDE_MCP_SERVER_ID }); + const history = await context.session.history(); + const messages = await convertToAiSdkMessages({ + messages: [...history.items, ...context.messages], + chatkit: context.chatkit, + onUnprocessed: { + sentry: { + "sentry.issue.created": sentryIssueCreatedMessage, + }, + }, + }); + const { mcp, closeMcp } = await createMCPClient({ + client: tilde, + serverId: env.TILDE_MCP_SERVER_ID, + }); let sandbox: RemediationSandbox | undefined; - async function closeResources() { for (const result of await Promise.allSettled([sandbox?.close(), closeMcp()])) if (result.status === "rejected") console.error("Could not clean up remediation resource.", result.reason); } + async function closeResources() { + for (const result of await Promise.allSettled([ + sandbox?.close(), + closeMcp(), + ])) { + if (result.status === "rejected") { + console.error( + "Could not clean up remediation resource.", + result.reason, + ); + } + } + } try { const remoteTools = await mcp.tools(); - sandbox = await createRemediationSandbox(env, tilde, remoteTools, signal); + sandbox = await createRemediationSandbox( + env, + tilde, + remoteTools, + request.signal, + ); const activeSandbox = sandbox; - signal.addEventListener("abort", () => void activeSandbox.close(), { once: true }); + request.signal.addEventListener( + "abort", + () => void activeSandbox.close(), + { once: true }, + ); const result = streamText({ - abortSignal: signal, - messages: [ - ...(await convertToModelMessages(messages)), - { role: "user", content: signalPrompt }, - ], + abortSignal: request.signal, + messages: await convertToModelMessages(messages), model: openai(env.OPENAI_MODEL), stopWhen: stepCountIs(60), - system: remediationPrompt(activeSandbox.id, activeSandbox.repositoryPath, env.GITHUB_REPOSITORY), + system: remediationPrompt( + activeSandbox.id, + activeSandbox.repositoryPath, + env.GITHUB_REPOSITORY, + ), tools: agentRemediationTools(remoteTools), - async onError({ error }) { console.error("Sentry remediation failed.", error); await closeResources(); }, - async onAbort() { await closeResources(); }, - async onFinish() { await closeResources(); }, + async onError({ error }) { + console.error("Sentry remediation failed.", error); + await closeResources(); + }, + async onAbort() { + await closeResources(); + }, + async onFinish() { + await closeResources(); + }, + }); + return result.toUIMessageStreamResponse({ + consumeSseStream: consumeStream, + originalMessages: messages, }); - return result.toUIMessageStreamResponse({ consumeSseStream: consumeStream, originalMessages: messages }); - } catch (error) { await closeResources(); throw error; } - }, -}); - -async function waitForSentrySignalPrompt(sessionId: string, signal: AbortSignal): Promise { - let lastError: unknown; - for (let attempt = 0; attempt < 20; attempt += 1) { - try { - const rawMessages = await tilde.chatkit.listMessageHistory({ sessionId, pageSize: 100 }); - return latestSentrySignalPrompt(rawMessages.items); } catch (error) { - lastError = error; - if (attempt === 19) break; - await abortableDelay(250, signal); + await closeResources(); + throw error; } - } - throw lastError; -} - -function abortableDelay(delayMs: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(signal.reason); - return new Promise((resolve, reject) => { - const onAbort = () => { - clearTimeout(timeout); - reject(signal.reason); - }; - const timeout = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, delayMs); - signal.addEventListener("abort", onAbort, { once: true }); - }); -} + }, +}); diff --git a/sentry-remediation-bot/lib/remediation/signal.ts b/sentry-remediation-bot/lib/remediation/signal.ts index 61d72e2..41d9666 100644 --- a/sentry-remediation-bot/lib/remediation/signal.ts +++ b/sentry-remediation-bot/lib/remediation/signal.ts @@ -1,40 +1,32 @@ -import { z } from "zod"; +import type { SentrySignalByType } from "@trytilde/harness-sdk-vercel-ai-node"; +import type { UIMessage } from "ai"; -const sentrySignalMessageSchema = z.object({ - data: z.object({ - data: z.object({ - event: z.object({ eventID: z.string().min(1) }).passthrough(), - issue: z - .object({ - id: z.string().min(1), - permalink: z.string().url(), - shortId: z.string().min(1), - title: z.string().min(1), - }) - .passthrough(), - }), - }), - metadata: z.object({ signal_type: z.literal("sentry.issue.created") }).passthrough(), - role: z.literal("system"), - summary: z.string().min(1), - type: z.literal("signal"), -}); +type SentryIssueCreatedSignal = + SentrySignalByType["sentry.issue.created"]; -/** Convert the newest raw Sentry signal message into the explicit task given to the model. */ -export function latestSentrySignalPrompt(messages: unknown[]): string { - for (const message of messages.toReversed()) { - const parsed = sentrySignalMessageSchema.safeParse(message); - if (!parsed.success) continue; - const { event, issue } = parsed.data.data.data; - return [ - "A new Sentry issue signal triggered this remediation run.", - `Summary: ${parsed.data.summary}`, - `Issue: ${issue.shortId} (${issue.id})`, - `URL: ${issue.permalink}`, - `Event ID: ${event.eventID}`, - `Title: ${issue.title}`, - "Investigate this exact issue and complete the remediation workflow described in the system prompt.", - ].join("\n"); - } - throw new Error("The remediation session does not contain a valid Sentry issue-created signal"); +/** Convert a typed Sentry issue-created signal into the model's remediation task. */ +export function sentryIssueCreatedMessage( + signal: SentryIssueCreatedSignal, +): UIMessage { + const { event, issue } = signal.data.data; + const eventId = + typeof event === "object" && + event !== null && + "eventID" in event && + typeof event.eventID === "string" + ? event.eventID + : "not provided"; + const prompt = `A new Sentry issue signal triggered this remediation run. +Summary: ${signal.summary ?? issue.title} +Issue: ${issue.shortId ?? issue.id} (${issue.id}) +URL: ${issue.permalink ?? "not provided"} +Event ID: ${eventId} +Title: ${issue.title} +Investigate this exact issue and complete the remediation workflow described in the system prompt.`; + + return { + id: signal.id, + role: "user", + parts: [{ type: "text", text: prompt }], + }; } diff --git a/sentry-remediation-bot/package.json b/sentry-remediation-bot/package.json index 54c623a..0a30f1f 100644 --- a/sentry-remediation-bot/package.json +++ b/sentry-remediation-bot/package.json @@ -12,7 +12,7 @@ }, "scripts": { "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "node --test lib/remediation/issue-session-key.test.mjs" }, "dependencies": { - "@ai-sdk/mcp": "1.0.59", "@ai-sdk/openai": "3.0.81", "@trytilde/harness-sdk": "0.1.2", "@trytilde/harness-sdk-vercel-ai-node": "0.1.2", "ai": "6.0.220", "next": "16.2.12", "react": "19.2.8", "react-dom": "19.2.8", "zod": "4.4.3" + "@ai-sdk/mcp": "1.0.59", "@ai-sdk/openai": "3.0.81", "@trytilde/harness-sdk": "0.1.2", "@trytilde/harness-sdk-vercel-ai-node": "0.2.0", "ai": "6.0.220", "next": "16.2.12", "react": "19.2.8", "react-dom": "19.2.8", "zod": "4.4.3" }, "devDependencies": { "@types/node": "24.10.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "eslint": "9.39.1", "eslint-config-next": "16.2.12", "typescript": "5.9.3" } } diff --git a/sentry-remediation-bot/pnpm-lock.yaml b/sentry-remediation-bot/pnpm-lock.yaml index 6929538..254a6a4 100644 --- a/sentry-remediation-bot/pnpm-lock.yaml +++ b/sentry-remediation-bot/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: 0.1.2 version: 0.1.2 '@trytilde/harness-sdk-vercel-ai-node': - specifier: 0.1.2 - version: 0.1.2(@ai-sdk/mcp@1.0.59(zod@4.4.3))(@trytilde/harness-sdk@0.1.2)(ai@6.0.220(zod@4.4.3)) + specifier: 0.2.0 + version: 0.2.0(@ai-sdk/mcp@1.0.59(zod@4.4.3))(@trytilde/harness-sdk@0.1.2)(ai@6.0.220(zod@4.4.3)) ai: specifier: 6.0.220 version: 6.0.220(zod@4.4.3) @@ -540,8 +540,8 @@ packages: '@trytilde/api-client@0.1.2': resolution: {integrity: sha512-kpwS1CgRqVrTrcYJ5ZrZm+iA1gamx5CO9YRzTT9rEdn5bIOTsrbHyTCvbBTH9XrBD3Q5K3dFenW0H2Ku7NMScA==} - '@trytilde/harness-sdk-vercel-ai-node@0.1.2': - resolution: {integrity: sha512-gk/1KH6Rb6kPUpUhtvkJqB5rHz5eBu6WDewcs0FbWJIZZ2A+uf0kr11p0WqklVgbrjn7tJjNZN6Un/Gg01lC2w==} + '@trytilde/harness-sdk-vercel-ai-node@0.2.0': + resolution: {integrity: sha512-JTCVeI9ZtRzM25VU+/Zyg4dQSp1aUUwqaKuRnw0QJC3DuZ52r9c7Lkw6lkkj50/ksSeaUWCkRyL2xqzpkGKwPg==} peerDependencies: '@ai-sdk/mcp': ^1.0.46 '@trytilde/harness-sdk': ^0.1.2 @@ -2441,7 +2441,7 @@ snapshots: '@trytilde/api-client@0.1.2': {} - '@trytilde/harness-sdk-vercel-ai-node@0.1.2(@ai-sdk/mcp@1.0.59(zod@4.4.3))(@trytilde/harness-sdk@0.1.2)(ai@6.0.220(zod@4.4.3))': + '@trytilde/harness-sdk-vercel-ai-node@0.2.0(@ai-sdk/mcp@1.0.59(zod@4.4.3))(@trytilde/harness-sdk@0.1.2)(ai@6.0.220(zod@4.4.3))': dependencies: '@ai-sdk/mcp': 1.0.59(zod@4.4.3) '@trytilde/harness-sdk': 0.1.2