From a24ced5ffff98a4795d1771ab6fa85563a2f458c Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Mon, 28 Sep 2026 15:26:34 +0200 Subject: [PATCH] agent-tools + MCP: incident tools Co-Authored-By: Claude Opus 5.5 --- apps/dashboard/src/app/api/chat/route.ts | 5 + .../chat/tool-renderers/incidents.tsx | 131 ++++++ .../components/chat/tool-renderers/index.tsx | 43 ++ apps/dashboard/src/lib/agent-tools/adapter.ts | 9 +- .../src/libs/test/doubles/page-urls.mock.ts | 3 + apps/server/src/routes/mcp/evals/cases.ts | 15 + apps/server/src/routes/mcp/handler.test.ts | 6 + apps/server/src/routes/mcp/server.ts | 2 + apps/server/src/routes/mcp/tools/incident.ts | 29 ++ .../src/routes/mcp/tools/registry-adapter.ts | 6 +- apps/server/src/routes/slack/agent.ts | 6 +- apps/server/src/routes/slack/page-urls.ts | 4 + .../src/routes/slack/presenters/incident.ts | 27 ++ .../src/routes/slack/presenters/index.ts | 8 + .../src/routes/slack/registry-runner.test.ts | 8 +- .../src/routes/slack/registry-runner.ts | 5 +- apps/server/src/routes/slack/system-prompt.ts | 12 +- .../agent-tools/__tests__/incident.test.ts | 84 ++++ packages/services/src/agent-tools/incident.ts | 376 ++++++++++++++++++ packages/services/src/agent-tools/index.ts | 32 ++ packages/services/src/agent-tools/prompt.ts | 16 +- .../services/src/agent-tools/status-report.ts | 11 + packages/services/src/agent-tools/types.ts | 4 + 23 files changed, 831 insertions(+), 11 deletions(-) create mode 100644 apps/dashboard/src/components/chat/tool-renderers/incidents.tsx create mode 100644 apps/server/src/routes/mcp/tools/incident.ts create mode 100644 apps/server/src/routes/slack/presenters/incident.ts create mode 100644 packages/services/src/agent-tools/__tests__/incident.test.ts create mode 100644 packages/services/src/agent-tools/incident.ts diff --git a/apps/dashboard/src/app/api/chat/route.ts b/apps/dashboard/src/app/api/chat/route.ts index bd135dbf..b6ca81a9 100644 --- a/apps/dashboard/src/app/api/chat/route.ts +++ b/apps/dashboard/src/app/api/chat/route.ts @@ -3,6 +3,7 @@ import { type ChatStoredMessage, storedMessageSchema, } from "@openstatus/db/src/schema"; +import { isFeatureEnabled } from "@openstatus/services"; import { agentTools, buildAgentSystemPrompt, @@ -154,6 +155,10 @@ export async function POST(req: NextRequest) { workspaceName: ctx.workspace.name ?? "Unknown", surface: "dashboard", canNotifySubscribers: ctx.workspace.limits["status-subscribers"] === true, + incidentManagement: isFeatureEnabled( + ctx.workspace, + "incident-management", + ), }), messages: modelMessages, tools, diff --git a/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx b/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx new file mode 100644 index 00000000..870dabdf --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx @@ -0,0 +1,131 @@ +import type { + AgentToolInput, + AgentToolOutput, +} from "@openstatus/services/agent-tools"; + +import type { ChangeRow } from "@/components/common/changes-table"; +import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { TableCellText } from "@/components/data-table/table-cell-text"; + +import type { DetailsTableData } from "./details-table"; +import type { ResultTableData } from "./result-table"; + +export function listIncidentsTable( + output: AgentToolOutput<"list_incidents">, +): ResultTableData<"title" | "severity" | "status" | "commander" | "id"> { + const items = output?.items ?? []; + return { + empty: "No incidents.", + columns: [ + { key: "title", header: "Title" }, + { key: "severity", header: "Severity" }, + { key: "status", header: "Status" }, + { key: "commander", header: "Commander" }, + { key: "id", header: "ID" }, + ], + rows: items.map((i) => ({ + id: i.id, + cells: { + title: , + severity: , + status: ( + + ), + commander: , + id: , + }, + })), + }; +} + +export function getIncidentDetails( + output: AgentToolOutput<"get_incident">, +): DetailsTableData { + return { + sections: [ + { + rows: [ + { label: "ID", value: }, + { label: "Title", value: }, + { + label: "Severity", + value: , + }, + { label: "Status", value: }, + { + label: "Commander", + value: , + }, + { + label: "Started", + value: , + }, + { + label: "Status report", + value: , + }, + ], + }, + { + title: "Timeline", + rows: output.events.slice(0, 10).map((e) => ({ + label: e.type.replaceAll("_", " "), + value: , + })), + }, + ], + }; +} + +export function declareIncidentChanges( + input: AgentToolInput<"declare_incident">, + result?: { id: number }, +): ChangeRow[] { + const changes: ChangeRow[] = []; + if (result) changes.push({ field: "id", after: result.id }); + changes.push( + { field: "title", after: input.title }, + { field: "severity", after: input.severity }, + ); + if (input.summary) changes.push({ field: "summary", after: input.summary }); + if (input.commanderId !== undefined) { + changes.push({ field: "commanderId", after: input.commanderId }); + } + if (input.startedAt) { + changes.push({ field: "startedAt", after: input.startedAt }); + } + if (input.statusReportId !== undefined) { + changes.push({ field: "statusReportId", after: input.statusReportId }); + } + return changes; +} + +export function updateIncidentChanges( + input: AgentToolInput<"update_incident">, +): ChangeRow[] { + const changes: ChangeRow[] = [{ field: "incidentId", after: input.id }]; + for (const field of [ + "title", + "severity", + "summary", + "commanderId", + "startedAt", + ] as const) { + if (input[field] !== undefined) { + changes.push({ field, after: input[field] }); + } + } + return changes; +} + +export function resolveIncidentChanges( + input: AgentToolInput<"resolve_incident">, +): ChangeRow[] { + const changes: ChangeRow[] = [ + { field: "incidentId", after: input.id }, + { field: "status", after: "resolved" }, + ]; + if (input.note) changes.push({ field: "note", after: input.note }); + return changes; +} diff --git a/apps/dashboard/src/components/chat/tool-renderers/index.tsx b/apps/dashboard/src/components/chat/tool-renderers/index.tsx index bd843473..65ff7a0a 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/index.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/index.tsx @@ -19,6 +19,13 @@ import { getMonitorDetails } from "./get-monitor"; import { getMonitorStatusTable } from "./get-monitor-status"; import { getMonitorSummaryDetails } from "./get-monitor-summary"; import { getResponseLogDetails } from "./get-response-log"; +import { + declareIncidentChanges, + getIncidentDetails, + listIncidentsTable, + resolveIncidentChanges, + updateIncidentChanges, +} from "./incidents"; import { listAuditLogsTable } from "./list-audit-logs"; import { listMaintenancesTable } from "./list-maintenances"; import { listMonitorsTable } from "./list-monitors"; @@ -141,6 +148,42 @@ export const toolRenderers: ToolRendererRegistry = { ), summary: (o) => `ID ${o.id}`, }, + list_incidents: { + renderResult: ({ output }) => ( + + ), + summary: (o) => itemsCountSummary(o.items), + }, + get_incident: { + renderResult: ({ output }) => ( + + ), + summary: (o) => `${o.severity} · ${o.status}`, + }, + declare_incident: { + renderDraft: (input) => declareIncidentChanges(input), + renderResult: ({ input, output }) => ( + + ), + summary: (o) => `ID ${o.id}`, + }, + update_incident: { + renderDraft: (input) => updateIncidentChanges(input), + renderResult: ({ input }) => ( + + ), + summary: (o) => `ID ${o.id}`, + }, + resolve_incident: { + renderDraft: (input) => resolveIncidentChanges(input), + renderResult: ({ input }) => ( + + ), + summary: (o) => `resolved · ID ${o.id}`, + }, + add_incident_note: { + summary: (o) => `note added to incident ${o.incidentId}`, + }, list_monitors: { renderResult: ({ output }) => ( diff --git a/apps/dashboard/src/lib/agent-tools/adapter.ts b/apps/dashboard/src/lib/agent-tools/adapter.ts index c4ba216b..aa3b1879 100644 --- a/apps/dashboard/src/lib/agent-tools/adapter.ts +++ b/apps/dashboard/src/lib/agent-tools/adapter.ts @@ -1,7 +1,8 @@ import type { ServiceContext } from "@openstatus/services"; -import type { - AgentToolRegistry, - AnyAgentTool, +import { + type AgentToolRegistry, + type AnyAgentTool, + isAgentToolAvailable, } from "@openstatus/services/agent-tools"; import { type Tool, tool } from "ai"; @@ -27,7 +28,7 @@ export function toAiSdkTools( const out: Record = {}; for (const name in registry) { const t = registry[name]; - if (!t) continue; + if (!t || !isAgentToolAvailable(t, ctx.workspace)) continue; out[name] = toolToAiSdkTool(t, ctx); } return out; diff --git a/apps/server/src/libs/test/doubles/page-urls.mock.ts b/apps/server/src/libs/test/doubles/page-urls.mock.ts index b0dae007..7d5f4965 100644 --- a/apps/server/src/libs/test/doubles/page-urls.mock.ts +++ b/apps/server/src/libs/test/doubles/page-urls.mock.ts @@ -32,3 +32,6 @@ export const getStatusReportLink = ( title: `Report ${statusReportId}`, url: `https://example.openstatus.dev/events/report/${statusReportId}`, }); + +export const getIncidentDashboardUrl = (incidentId: number): string => + `https://app.openstatus.dev/incidents/${incidentId}`; diff --git a/apps/server/src/routes/mcp/evals/cases.ts b/apps/server/src/routes/mcp/evals/cases.ts index a3577f8f..5508816f 100644 --- a/apps/server/src/routes/mcp/evals/cases.ts +++ b/apps/server/src/routes/mcp/evals/cases.ts @@ -51,6 +51,21 @@ export const cases: EvalCase[] = [ expectedTool: "list_maintenances", }, + // Managed incidents: internal, distinct from status reports + { + id: "select.declare_incident", + prompt: + "Declare an incident: checkout is failing for everyone, critical severity.", + expectedTool: "declare_incident", + requiredArgs: ["title", "severity"], + }, + { + id: "prereq.note_lists_incidents_first", + prompt: + "Add a note to the checkout incident that we rolled back the deploy.", + expectedTool: "list_incidents", + }, + // Prereq compliance: must call list_status_pages first when creating { id: "prereq.create_report_lists_pages_first", diff --git a/apps/server/src/routes/mcp/handler.test.ts b/apps/server/src/routes/mcp/handler.test.ts index 872cb32c..6a180317 100644 --- a/apps/server/src/routes/mcp/handler.test.ts +++ b/apps/server/src/routes/mcp/handler.test.ts @@ -119,17 +119,21 @@ describe("MCP transport", () => { const tools = (body.result as { tools: { name: string }[] }).tools; const names = tools.map((t) => t.name).sort(); expect(names).toEqual([ + "add_incident_note", "add_status_report_update", "create_maintenance", "create_status_report", + "declare_incident", "get_audit_log", "get_content_page", "get_doc_page", + "get_incident", "get_monitor", "get_monitor_status", "get_monitor_summary", "get_response_log", "list_audit_logs", + "list_incidents", "list_maintenances", "list_monitors", "list_notifications", @@ -138,9 +142,11 @@ describe("MCP transport", () => { "list_response_logs", "list_status_pages", "list_status_reports", + "resolve_incident", "resolve_status_report", "search_content", "search_docs", + "update_incident", "update_status_report", ]); }); diff --git a/apps/server/src/routes/mcp/server.ts b/apps/server/src/routes/mcp/server.ts index 28005969..ec14c2b1 100644 --- a/apps/server/src/routes/mcp/server.ts +++ b/apps/server/src/routes/mcp/server.ts @@ -5,6 +5,7 @@ import packageJson from "../../../package.json" with { type: "json" }; import { registerPublicResources } from "./resources"; import { registerAuditTools } from "./tools/audit"; import { registerContentTools } from "./tools/content"; +import { registerIncidentTools } from "./tools/incident"; import { registerMaintenanceTools } from "./tools/maintenance"; import { registerMonitorTools } from "./tools/monitor"; import { registerNotificationTools } from "./tools/notification"; @@ -31,6 +32,7 @@ export function createMcpServer(ctx: ServiceContext): McpServer { registerPublicResources(server); registerPageTools(server, ctx); registerStatusReportTools(server, ctx); + registerIncidentTools(server, ctx); registerMaintenanceTools(server, ctx); registerMonitorTools(server, ctx); registerNotificationTools(server, ctx); diff --git a/apps/server/src/routes/mcp/tools/incident.ts b/apps/server/src/routes/mcp/tools/incident.ts new file mode 100644 index 00000000..ae11e998 --- /dev/null +++ b/apps/server/src/routes/mcp/tools/incident.ts @@ -0,0 +1,29 @@ +import type { + McpServer, + RegisteredTool, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ServiceContext } from "@openstatus/services"; +import { + addIncidentNoteTool, + declareIncidentTool, + getIncidentTool, + listIncidentsTool, + resolveIncidentTool, + updateIncidentTool, +} from "@openstatus/services/agent-tools"; + +import { registerRegistryTools } from "./registry-adapter"; + +export function registerIncidentTools( + server: McpServer, + ctx: ServiceContext, +): Map { + return registerRegistryTools(server, ctx, [ + listIncidentsTool, + getIncidentTool, + declareIncidentTool, + updateIncidentTool, + resolveIncidentTool, + addIncidentNoteTool, + ]); +} diff --git a/apps/server/src/routes/mcp/tools/registry-adapter.ts b/apps/server/src/routes/mcp/tools/registry-adapter.ts index e069638f..982d436a 100644 --- a/apps/server/src/routes/mcp/tools/registry-adapter.ts +++ b/apps/server/src/routes/mcp/tools/registry-adapter.ts @@ -3,7 +3,10 @@ import type { RegisteredTool, } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ServiceContext } from "@openstatus/services"; -import type { AnyAgentTool } from "@openstatus/services/agent-tools"; +import { + type AnyAgentTool, + isAgentToolAvailable, +} from "@openstatus/services/agent-tools"; import { ZodObject, type ZodRawShape, type ZodType } from "zod"; import { runTool } from "../adapter"; @@ -24,6 +27,7 @@ export function registerRegistryTool( ctx: ServiceContext, tool: AnyAgentTool, ): RegisteredTool | undefined { + if (!isAgentToolAvailable(tool, ctx.workspace)) return undefined; const inputShape = assertShape(tool.inputSchema, tool.name, "input"); const outputShape = assertShape(tool.outputSchema, tool.name, "output"); diff --git a/apps/server/src/routes/slack/agent.ts b/apps/server/src/routes/slack/agent.ts index dceeb448..ff191e87 100644 --- a/apps/server/src/routes/slack/agent.ts +++ b/apps/server/src/routes/slack/agent.ts @@ -1,5 +1,5 @@ import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; -import type { ServiceContext } from "@openstatus/services"; +import { isFeatureEnabled, type ServiceContext } from "@openstatus/services"; import { stepCountIs, streamText } from "ai"; import type { ModelMessage, Tool } from "ai"; @@ -111,7 +111,9 @@ export async function runAgent( const result = streamText({ model: MODEL, - system: buildSystemPrompt(workspace.name ?? "Unknown", contextNote), + system: buildSystemPrompt(workspace.name ?? "Unknown", contextNote, { + incidentManagement: isFeatureEnabled(workspace, "incident-management"), + }), messages, tools, stopWhen: stepCountIs(MAX_STEPS), diff --git a/apps/server/src/routes/slack/page-urls.ts b/apps/server/src/routes/slack/page-urls.ts index 02ce1f95..7699a0d1 100644 --- a/apps/server/src/routes/slack/page-urls.ts +++ b/apps/server/src/routes/slack/page-urls.ts @@ -29,6 +29,10 @@ function getDashboardBaseUrl(): string { : "http://localhost:3001"; } +export function getIncidentDashboardUrl(incidentId: number): string { + return `${getDashboardBaseUrl()}/incidents/${incidentId}`; +} + /** * Resolve a page id to its dashboard link — the page title plus the internal * dashboard URL (not the public status page). Scoped to the workspace so a diff --git a/apps/server/src/routes/slack/presenters/incident.ts b/apps/server/src/routes/slack/presenters/incident.ts new file mode 100644 index 00000000..7842bb35 --- /dev/null +++ b/apps/server/src/routes/slack/presenters/incident.ts @@ -0,0 +1,27 @@ +import type { + AgentToolInput, + AgentToolOutput, +} from "@openstatus/services/agent-tools"; + +import { getIncidentDashboardUrl } from "../page-urls"; +import type { Presenter } from "./types"; + +function link(id: number): string { + return `<${getIncidentDashboardUrl(id)}|Open in openstatus>`; +} + +export const declareIncidentPresenter: Presenter = ({ output }) => { + const o = output as AgentToolOutput<"declare_incident">; + return `:rotating_light: Incident *${o.title}* declared (${o.severity}).\n${link(o.id)}`; +}; + +export const updateIncidentPresenter: Presenter = ({ output }) => { + const o = output as AgentToolOutput<"update_incident">; + return `:white_check_mark: Incident *${o.title}* updated.\n${link(o.id)}`; +}; + +export const resolveIncidentPresenter: Presenter = ({ input, output }) => { + const i = input as AgentToolInput<"resolve_incident">; + const o = output as AgentToolOutput<"resolve_incident">; + return `:white_check_mark: Incident *${o.title}* resolved.${i.note ? `\n> ${i.note}` : ""}\n${link(o.id)}`; +}; diff --git a/apps/server/src/routes/slack/presenters/index.ts b/apps/server/src/routes/slack/presenters/index.ts index fcbcce7f..c53ee796 100644 --- a/apps/server/src/routes/slack/presenters/index.ts +++ b/apps/server/src/routes/slack/presenters/index.ts @@ -2,6 +2,11 @@ import type { ServiceContext } from "@openstatus/services"; import type { AnyAgentTool } from "@openstatus/services/agent-tools"; import { defaultPresenter } from "./default"; +import { + declareIncidentPresenter, + resolveIncidentPresenter, + updateIncidentPresenter, +} from "./incident"; import { createMaintenancePresenter } from "./maintenance"; import { addStatusReportUpdatePresenter, @@ -17,6 +22,9 @@ export const presenters: Record = { update_status_report: updateStatusReportPresenter, resolve_status_report: resolveStatusReportPresenter, create_maintenance: createMaintenancePresenter, + declare_incident: declareIncidentPresenter, + update_incident: updateIncidentPresenter, + resolve_incident: resolveIncidentPresenter, }; export async function renderToolResult(args: { diff --git a/apps/server/src/routes/slack/registry-runner.test.ts b/apps/server/src/routes/slack/registry-runner.test.ts index d0854fad..bd1c0a57 100644 --- a/apps/server/src/routes/slack/registry-runner.test.ts +++ b/apps/server/src/routes/slack/registry-runner.test.ts @@ -379,10 +379,16 @@ describe("buildSystemPrompt coverage", () => { // buildSlackTools hands the model every registry tool; a tool the prompt // never mentions is one the model won't reach for (or will misuse). test("mentions every tool the Slack agent is given", () => { - const prompt = buildSystemPrompt("Acme Corp"); + const prompt = buildSystemPrompt("Acme Corp", undefined, { + incidentManagement: true, + }); const missing = Object.keys(agentTools).filter( (name) => !new RegExp(`\\b${name}\\b`).test(prompt), ); expect(missing).toEqual([]); }); + + test("leaves incident tools out when the feature is off", () => { + expect(buildSystemPrompt("Acme Corp")).not.toContain("declare_incident"); + }); }); diff --git a/apps/server/src/routes/slack/registry-runner.ts b/apps/server/src/routes/slack/registry-runner.ts index 20ed0f87..2d46f8f6 100644 --- a/apps/server/src/routes/slack/registry-runner.ts +++ b/apps/server/src/routes/slack/registry-runner.ts @@ -3,6 +3,7 @@ import { type AgentToolName, type AnyAgentTool, agentTools, + isAgentToolAvailable, } from "@openstatus/services/agent-tools"; import { type Tool, tool } from "ai"; import { ZodObject, type ZodType, type z } from "zod"; @@ -45,7 +46,9 @@ export function buildSlackTools( ): Record { const out: Record = {}; for (const name of Object.keys(agentTools) as AgentToolName[]) { - out[name] = buildTool(agentTools[name] as AnyAgentTool, ctx); + const t: AnyAgentTool = agentTools[name]; + if (!isAgentToolAvailable(t, ctx.workspace)) continue; + out[name] = buildTool(t, ctx); } return { ...out, ...extras }; } diff --git a/apps/server/src/routes/slack/system-prompt.ts b/apps/server/src/routes/slack/system-prompt.ts index 3fd272e0..dd302905 100644 --- a/apps/server/src/routes/slack/system-prompt.ts +++ b/apps/server/src/routes/slack/system-prompt.ts @@ -1,8 +1,18 @@ // dependency-free leaf so the prompt test doesn't link the agent's module // graph (ai + services) — bun test flakes on concurrent linking of large graphs. +const INCIDENT_SECTION = ` + +Managed incidents (internal, never published): +- Three things are called "incident". A managed incident (list_incidents, get_incident, declare_incident, update_incident, resolve_incident, add_incident_note) is the team's internal record with a severity, a commander and a timeline. A status report is the PUBLIC communication. Monitor downtime is detected automatically. +- "declare an incident", "open an incident" -> declare_incident (approval card, publishes nothing). Then offer a status report, passing incidentId to create_status_report to link them. +- "note that…", "add to the timeline" -> add_incident_note (runs right away, no card). +- "the incident is resolved" with a managed incident in play -> resolve_incident; if its linked status report is still open, draft resolve_status_report too. +- Before referencing a managed incident, call list_incidents. Severity: critical = major outage or data loss, major = significant degradation, minor = limited impact.`; + export function buildSystemPrompt( workspaceName: string, contextNote?: string, + options: { incidentManagement?: boolean } = {}, ): string { // Intentional: a per-call ISO timestamp defeats Anthropic/Gateway // prompt caching, but the agent needs minute-level precision to parse @@ -108,5 +118,5 @@ Maintenance scheduling: - Parse natural language dates into ISO 8601 format. Convert relative dates like "next Friday from 2-3 PM" into proper ISO 8601 timestamps. - If the user doesn't specify a timezone, default to UTC and mention that in your response. - The "from" time must be before the "to" time. -- Write a professional maintenance message describing what will happen during the window.${contextNote ? `\n\n${contextNote.trim()}` : ""}`; +- Write a professional maintenance message describing what will happen during the window.${options.incidentManagement ? INCIDENT_SECTION : ""}${contextNote ? `\n\n${contextNote.trim()}` : ""}`; } diff --git a/packages/services/src/agent-tools/__tests__/incident.test.ts b/packages/services/src/agent-tools/__tests__/incident.test.ts new file mode 100644 index 00000000..b6d3dbcd --- /dev/null +++ b/packages/services/src/agent-tools/__tests__/incident.test.ts @@ -0,0 +1,84 @@ +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + makeUserCtx, + withTestTransaction, +} from "../../../test/helpers"; +import type { Workspace } from "../../types"; +import { + addIncidentNoteTool, + agentTools, + declareIncidentTool, + getIncidentTool, + isAgentToolAvailable, + listIncidentsTool, + resolveIncidentTool, + updateIncidentTool, +} from "../index"; + +let workspace: Workspace; +let userId: number; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + workspace = fixture.workspace; + userId = fixture.userId; +}); + +describe("incident agent tools", () => { + test("declare defaults the commander to the requester, then the full loop", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...makeUserCtx(workspace, { userId }), db: tx }; + const declared = await declareIncidentTool.run({ + ctx, + input: declareIncidentTool.inputSchema.parse({ + title: "Checkout failing", + severity: "critical", + }), + }); + await updateIncidentTool.run({ + ctx, + input: { id: declared.id, severity: "major" }, + }); + await addIncidentNoteTool.run({ + ctx, + input: { id: declared.id, message: "Rolled back" }, + }); + const resolved = await resolveIncidentTool.run({ + ctx, + input: { id: declared.id, note: "Fixed" }, + }); + expect(resolved.status).toBe("resolved"); + + const list = await listIncidentsTool.run({ + ctx, + input: listIncidentsTool.inputSchema.parse({}), + }); + expect(list.items.map((i) => i.id)).toContain(declared.id); + + const detail = await getIncidentTool.run({ + ctx, + input: { id: declared.id }, + }); + expect(detail.commander?.id).toBe(userId); + expect(detail.severity).toBe("major"); + expect(detail.events.map((e) => e.type)).toEqual([ + "resolved", + "note", + "severity_changed", + "declared", + ]); + expect(getIncidentTool.outputSchema.parse(detail)).toBeDefined(); + }); + }); + + test("every incident tool is feature-gated, the rest are not", () => { + for (const tool of Object.values(agentTools)) { + const isIncident = tool.name.includes("incident"); + expect(tool.feature === "incident-management").toBe(isIncident); + } + expect(isAgentToolAvailable(listIncidentsTool, { id: 1 })).toBe(true); + }); +}); diff --git a/packages/services/src/agent-tools/incident.ts b/packages/services/src/agent-tools/incident.ts new file mode 100644 index 00000000..0b28acde --- /dev/null +++ b/packages/services/src/agent-tools/incident.ts @@ -0,0 +1,376 @@ +import { + incidentSeverity, + incidentStatus, +} from "@openstatus/db/src/schema/incidents/constants"; +import { z } from "zod"; + +import { tryGetActorUserId } from "../context"; +import { NotFoundError } from "../errors"; +import { + addIncidentNote, + declareIncident, + displayName, + getIncident, + listIncidentEvents, + listIncidents, + setIncidentStatus, + updateIncident, +} from "../incident"; +import type { AgentTool, SummaryLine } from "./types"; + +const FEATURE = "incident-management"; + +const personSchema = z.object({ id: z.number().int(), name: z.string() }); + +type Person = { + id: number; + name: string | null; + firstName: string | null; + lastName: string | null; + email: string | null; +} | null; + +function person(row: Person) { + return row ? { id: row.id, name: displayName(row) } : null; +} + +const IncidentSummary = z.object({ + id: z.number().int(), + title: z.string(), + severity: z.enum(incidentSeverity), + status: z.enum(incidentStatus), + closed: z.boolean(), + commander: personSchema.nullable(), + declaredAt: z.string(), + startedAt: z.string(), + statusReportId: z.number().int().nullable(), + slackChannelId: z.string().nullable(), +}); + +const ListIncidentsInput = z.object({ + status: z + .array(z.enum(incidentStatus)) + .optional() + .describe("Only incidents in these statuses. Omit for all."), + limit: z.number().int().min(1).max(100).default(20), +}); + +const ListIncidentsOutput = z.object({ items: z.array(IncidentSummary) }); + +export const listIncidentsTool: AgentTool< + z.infer, + z.infer +> = { + name: "list_incidents", + description: + "List managed incidents in this workspace (declared by the team, not monitor downtime), open ones first.", + scope: "read", + destructive: false, + feature: FEATURE, + inputSchema: ListIncidentsInput, + outputSchema: ListIncidentsOutput, + async run({ ctx, input }) { + const rows = await listIncidents({ + ctx, + input: { status: input.status, limit: input.limit }, + }); + return { + items: rows.map((row) => ({ + id: row.id, + title: row.title, + severity: row.severity, + status: row.status, + closed: row.closedAt !== null, + commander: person(row.commander), + declaredAt: row.declaredAt.toISOString(), + startedAt: row.startedAt.toISOString(), + statusReportId: row.statusReportId, + slackChannelId: row.slackChannelId, + })), + }; + }, +}; + +const GetIncidentInput = z.object({ + id: z.number().int().describe("Incident id, from list_incidents."), +}); + +const GetIncidentOutput = IncidentSummary.extend({ + summary: z.string().nullable(), + declaredBy: personSchema.nullable(), + statusReport: z + .object({ id: z.number().int(), title: z.string(), status: z.string() }) + .nullable(), + events: z.array( + z.object({ + type: z.string(), + message: z.string().nullable(), + at: z.string(), + by: personSchema.nullable(), + }), + ), +}); + +export const getIncidentTool: AgentTool< + z.infer, + z.infer +> = { + name: "get_incident", + description: + "Get one managed incident with its timeline (newest first, up to 50 events) and linked status report.", + scope: "read", + destructive: false, + feature: FEATURE, + inputSchema: GetIncidentInput, + outputSchema: GetIncidentOutput, + async run({ ctx, input }) { + const row = await getIncident({ ctx, input }); + if (!row) throw new NotFoundError("incident", input.id); + const events = await listIncidentEvents({ ctx, input }); + return { + id: row.id, + title: row.title, + severity: row.severity, + status: row.status, + closed: row.closedAt !== null, + commander: person(row.commander), + declaredAt: row.declaredAt.toISOString(), + startedAt: row.startedAt.toISOString(), + statusReportId: row.statusReportId, + slackChannelId: row.slackChannelId, + summary: row.summary, + declaredBy: person(row.declaredByUser), + statusReport: row.statusReport + ? { + id: row.statusReport.id, + title: row.statusReport.title, + status: row.statusReport.status, + } + : null, + events: events.slice(0, 50).map((e) => ({ + type: e.type, + message: e.message, + at: e.createdAt.toISOString(), + by: person(e.createdByUser), + })), + }; + }, +}; + +const IncidentWriteOutput = z.object({ + id: z.number().int(), + title: z.string(), + severity: z.enum(incidentSeverity), + status: z.enum(incidentStatus), +}); + +function writeOutput(row: { + id: number; + title: string; + severity: z.infer["severity"]; + status: z.infer["status"]; +}) { + return { + id: row.id, + title: row.title, + severity: row.severity, + status: row.status, + }; +} + +const DeclareIncidentInput = z.object({ + title: z.string().min(1).max(256).describe("Short internal title."), + severity: z + .enum(incidentSeverity) + .describe( + "critical: major outage or data loss; major: significant degradation; minor: limited impact.", + ), + summary: z.string().max(4000).optional().describe("What is happening."), + commanderId: z + .number() + .int() + .optional() + .describe( + "Member leading the response. Omit to make the requester commander.", + ), + startedAt: z.iso + .datetime() + .optional() + .describe("When the impact began, ISO 8601. Omit for now."), + statusReportId: z + .number() + .int() + .optional() + .describe("Existing status report to link, from list_status_reports."), +}); + +export const declareIncidentTool: AgentTool< + z.infer, + z.infer +> = { + name: "declare_incident", + description: + "Declare a managed incident: the team's internal record of an outage, with a timeline and a commander. Internal only — nothing is published; use create_status_report for public communication.", + scope: "write", + destructive: true, + feature: FEATURE, + inputSchema: DeclareIncidentInput, + outputSchema: IncidentWriteOutput, + approval: { + summarize: (input) => ({ + title: `Declare incident: ${input.title}`, + lines: [ + { label: "Title", value: input.title }, + { label: "Severity", value: input.severity }, + ...optionalLines([ + ["Summary", input.summary], + ["Started at", input.startedAt], + [ + "Commander", + input.commanderId ? `#${input.commanderId}` : undefined, + ], + [ + "Status report", + input.statusReportId ? `#${input.statusReportId}` : undefined, + ], + ]), + ], + }), + verb: "declared", + }, + async run({ ctx, input }) { + const row = await declareIncident({ + ctx, + input: { + title: input.title, + severity: input.severity, + summary: input.summary, + commanderId: input.commanderId ?? tryGetActorUserId(ctx.actor), + startedAt: input.startedAt ? new Date(input.startedAt) : undefined, + statusReportId: input.statusReportId, + }, + }); + return writeOutput(row); + }, +}; + +const UpdateIncidentInput = z.object({ + id: z.number().int().describe("Incident id, from list_incidents."), + title: z.string().min(1).max(256).optional(), + severity: z.enum(incidentSeverity).optional(), + summary: z.string().max(4000).optional(), + commanderId: z.number().int().optional().describe("New commander's user id."), + startedAt: z.iso.datetime().optional().describe("Corrected start, ISO 8601."), +}); + +export const updateIncidentTool: AgentTool< + z.infer, + z.infer +> = { + name: "update_incident", + description: + "Change a managed incident's title, severity, summary, commander or start time. Internal only.", + scope: "write", + destructive: true, + feature: FEATURE, + inputSchema: UpdateIncidentInput, + outputSchema: IncidentWriteOutput, + approval: { + summarize: (input) => ({ + title: `Update incident #${input.id}`, + lines: optionalLines([ + ["Title", input.title], + ["Severity", input.severity], + ["Summary", input.summary], + ["Commander", input.commanderId ? `#${input.commanderId}` : undefined], + ["Started at", input.startedAt], + ]), + }), + verb: "updated", + }, + async run({ ctx, input }) { + const row = await updateIncident({ + ctx, + input: { + id: input.id, + title: input.title, + severity: input.severity, + summary: input.summary, + commanderId: input.commanderId, + startedAt: input.startedAt ? new Date(input.startedAt) : undefined, + }, + }); + return writeOutput(row); + }, +}; + +const ResolveIncidentInput = z.object({ + id: z.number().int().describe("Incident id, from list_incidents."), + note: z.string().max(10_000).optional().describe("What fixed it."), +}); + +export const resolveIncidentTool: AgentTool< + z.infer, + z.infer +> = { + name: "resolve_incident", + description: + "Mark a managed incident resolved. Does not touch its status report: resolve that separately with resolve_status_report if it is still open.", + scope: "write", + destructive: true, + feature: FEATURE, + inputSchema: ResolveIncidentInput, + outputSchema: IncidentWriteOutput, + approval: { + summarize: (input) => ({ + title: `Resolve incident #${input.id}`, + lines: optionalLines([["Note", input.note]]), + }), + verb: "resolved", + }, + async run({ ctx, input }) { + const row = await setIncidentStatus({ + ctx, + input: { + id: input.id, + status: "resolved", + note: input.note || undefined, + }, + }); + return writeOutput(row); + }, +}; + +const AddIncidentNoteInput = z.object({ + id: z.number().int().describe("Incident id, from list_incidents."), + message: z.string().min(1).max(10_000).describe("The note, markdown."), +}); + +const AddIncidentNoteOutput = z.object({ + incidentId: z.number().int(), + eventId: z.number().int(), +}); + +export const addIncidentNoteTool: AgentTool< + z.infer, + z.infer +> = { + name: "add_incident_note", + description: + "Append a note to a managed incident's timeline. Internal and append-only.", + scope: "write", + destructive: false, + feature: FEATURE, + inputSchema: AddIncidentNoteInput, + outputSchema: AddIncidentNoteOutput, + async run({ ctx, input }) { + const event = await addIncidentNote({ ctx, input }); + return { incidentId: input.id, eventId: event.id }; + }, +}; + +function optionalLines( + entries: Array<[string, string | undefined]>, +): SummaryLine[] { + return entries.flatMap(([label, value]) => (value ? [{ label, value }] : [])); +} diff --git a/packages/services/src/agent-tools/index.ts b/packages/services/src/agent-tools/index.ts index 28edff49..ec49ddd9 100644 --- a/packages/services/src/agent-tools/index.ts +++ b/packages/services/src/agent-tools/index.ts @@ -1,8 +1,18 @@ import type { z } from "zod"; +import { isFeatureEnabled } from "../features"; +import type { Workspace } from "../types"; import { getAuditLogTool, listAuditLogsTool } from "./audit"; import { getContentPageTool, searchContentTool } from "./content"; import { getDocPageTool, searchDocsTool } from "./docs"; +import { + addIncidentNoteTool, + declareIncidentTool, + getIncidentTool, + listIncidentsTool, + resolveIncidentTool, + updateIncidentTool, +} from "./incident"; import { createMaintenanceTool, listMaintenancesTool } from "./maintenance"; import { getMonitorStatusTool, @@ -28,6 +38,14 @@ import type { AnyAgentTool } from "./types"; export { getAuditLogTool, listAuditLogsTool } from "./audit"; export { getContentPageTool, searchContentTool } from "./content"; export { getDocPageTool, searchDocsTool } from "./docs"; +export { + addIncidentNoteTool, + declareIncidentTool, + getIncidentTool, + listIncidentsTool, + resolveIncidentTool, + updateIncidentTool, +} from "./incident"; export { createMaintenanceTool, listMaintenancesTool } from "./maintenance"; export { getMonitorStatusTool, @@ -82,6 +100,12 @@ export const agentTools = { add_status_report_update: addStatusReportUpdateTool, update_status_report: updateStatusReportTool, resolve_status_report: resolveStatusReportTool, + list_incidents: listIncidentsTool, + get_incident: getIncidentTool, + declare_incident: declareIncidentTool, + update_incident: updateIncidentTool, + resolve_incident: resolveIncidentTool, + add_incident_note: addIncidentNoteTool, list_maintenances: listMaintenancesTool, create_maintenance: createMaintenanceTool, list_monitors: listMonitorsTool, @@ -116,6 +140,14 @@ for (const tool of Object.values(agentTools) as AnyAgentTool[]) { } } +/** Whether a tool is offered in this workspace (feature-gated tools are hidden). */ +export function isAgentToolAvailable( + tool: AnyAgentTool, + workspace: Pick, +): boolean { + return !tool.feature || isFeatureEnabled(workspace, tool.feature); +} + /** * Iteration view used by adapters that don't care about the literal key. * Equivalent to `Object.values(agentTools)`. diff --git a/packages/services/src/agent-tools/prompt.ts b/packages/services/src/agent-tools/prompt.ts index 8c996015..742c5c47 100644 --- a/packages/services/src/agent-tools/prompt.ts +++ b/packages/services/src/agent-tools/prompt.ts @@ -16,6 +16,8 @@ export type AgentSystemPromptOptions = { * false the model passes `notify: false` and skips the yes/no prompt. */ canNotifySubscribers: boolean; + /** Whether managed-incident tools are offered in this workspace. */ + incidentManagement?: boolean; }; export function buildAgentSystemPrompt(opts: AgentSystemPromptOptions): string { @@ -39,6 +41,18 @@ Exception: after get_doc_page or get_content_page, DO synthesize an answer from const preamble = opts.preamble ? `${opts.preamble}\n\n` : ""; + const incidentSection = opts.incidentManagement + ? ` + +Managed incidents (internal): +- Three different things are called "incident". A managed incident (list_incidents, get_incident, declare_incident, update_incident, resolve_incident, add_incident_note) is the team's INTERNAL record: severity, commander, timeline. A status report is PUBLIC communication on a status page. Monitor downtime (activeIncidentCount on monitors) is detected automatically. +- "declare an incident", "open an incident", "we have a SEV" → declare_incident. It publishes nothing. Offer to create a status report afterwards and pass incidentId to create_status_report to link them. +- Before referencing a managed incident: call list_incidents. Never guess its id. +- "note that…", "add to the timeline", "log that…" → add_incident_note (internal, runs without confirmation). +- "the incident is fixed/resolved" with a managed incident in play → resolve_incident; if its linked status report is still open, ask whether to resolve that too (resolve_status_report). +- severity: critical = major outage or data loss, major = significant degradation, minor = limited impact. Ask when unclear.` + : ""; + // Workspaces without subscriber notify get a different rubric — asking // is wasted friction when the field is a server-side no-op anyway. const notifyStep = opts.canNotifySubscribers @@ -86,7 +100,7 @@ Lifecycle: - "provide an update", "we found the cause", "still investigating" → add_status_report_update. - "rename the report", "add a component" → update_status_report (metadata only — does not notify). - "it's fixed", "incident is resolved" → resolve_status_report (publishes a final update). -- Status progression hint: investigating → identified → monitoring → resolved. +- Status progression hint: investigating → identified → monitoring → resolved.${incidentSection} Inferring status from conversation: - "we have an incident" → investigating diff --git a/packages/services/src/agent-tools/status-report.ts b/packages/services/src/agent-tools/status-report.ts index 453fcaa2..10b4821c 100644 --- a/packages/services/src/agent-tools/status-report.ts +++ b/packages/services/src/agent-tools/status-report.ts @@ -205,6 +205,13 @@ const CreateStatusReportInputShape = z.object({ .datetime() .optional() .describe("Override the initial update's date. Defaults to now."), + incidentId: z + .number() + .int() + .optional() + .describe( + "Managed incident this report communicates, from list_incidents. Links them.", + ), notify: z .boolean() .describe( @@ -274,6 +281,9 @@ export const createStatusReportTool: AgentTool< }, ] : []), + ...(input.incidentId + ? [{ label: "Incident", value: `#${input.incidentId}` }] + : []), { label: "Message", value: input.message }, ], }), @@ -290,6 +300,7 @@ export const createStatusReportTool: AgentTool< pageComponentIds: input.pageComponentIds ?? [], componentImpacts: input.componentImpacts, date: input.date ? new Date(input.date) : new Date(), + incidentId: input.incidentId, }, }); let notified = false; diff --git a/packages/services/src/agent-tools/types.ts b/packages/services/src/agent-tools/types.ts index 48d29cdf..2d2b4447 100644 --- a/packages/services/src/agent-tools/types.ts +++ b/packages/services/src/agent-tools/types.ts @@ -2,6 +2,7 @@ import type { Scope } from "@openstatus/db/src/schema"; import type { ZodType, z } from "zod"; import type { ServiceContext } from "../context"; +import type { Feature } from "../features"; /** * Framework-agnostic tool descriptor shared by every adapter (MCP, AI SDK, Slack). @@ -20,6 +21,9 @@ export type AgentTool = { outputSchema: ZodType; run(args: { ctx: ServiceContext; input: TInput }): Promise; + /** Hidden from every surface unless this feature is on for the workspace. */ + feature?: Feature; + /** * Optional HITL metadata consumed by surfaces that orchestrate approval * outside `run()` (today: Slack). MCP relies on `destructiveHint`; the -- 2.51.2