Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.tgz
6 changes: 2 additions & 4 deletions code-review-bot/app/api/code-review/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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],
Expand Down
2 changes: 1 addition & 1 deletion code-review-bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions code-review-bot/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

127 changes: 76 additions & 51 deletions sentry-remediation-bot/app/api/sentry-remediation/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
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 {
agentRemediationTools,
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;
Expand All @@ -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<string> {
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<void> {
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 });
});
}
},
});
66 changes: 29 additions & 37 deletions sentry-remediation-bot/lib/remediation/signal.ts
Original file line number Diff line number Diff line change
@@ -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 }],
};
}
2 changes: 1 addition & 1 deletion sentry-remediation-bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
10 changes: 5 additions & 5 deletions sentry-remediation-bot/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading