diff --git a/apps/ai/scripts/grade-widget-eval.ts b/apps/ai/scripts/grade-widget-eval.ts index 97bcc43805..54df2cee92 100644 --- a/apps/ai/scripts/grade-widget-eval.ts +++ b/apps/ai/scripts/grade-widget-eval.ts @@ -12,7 +12,7 @@ * plus the task's own expectation (did they pick the panel type and unit the * request called for), which is the part no validator can check. * - * bun run --cwd apps/api scripts/grade-widget-eval.ts + * bun run --cwd apps/ai scripts/grade-widget-eval.ts */ import { readFileSync } from "node:fs" import { Schema, Array as Arr, String as Str } from "effect" diff --git a/apps/ai/src/chat/ChatSession.test.ts b/apps/ai/src/chat/ChatSession.test.ts index 7b391e02f2..5702794135 100644 --- a/apps/ai/src/chat/ChatSession.test.ts +++ b/apps/ai/src/chat/ChatSession.test.ts @@ -624,6 +624,47 @@ describe("ChatSession eviction recovery", () => { assert.strictEqual(again.runs[0]?.resume?.attempt, 1) }) + /** Nothing else moves a reply's row off `running` or tells the pull request it went unanswered. */ + it("gives an evicted pull request reply up without running it again, and says so", () => { + const { session, state } = recording() + session.beginTurn({ + sessionId: "org_test:prr-reply_1", + messageId: "kickoff", + text: "Answer the comment", + tenant: TENANT, + origin: { kind: "autonomous" }, + }) + const orphaned = claimedTurn(state)! + + const revived = recording(state) + revived.session.alarm() + + assert.isFalse(revived.session.running()) + assert.lengthOf(revived.runs, 1) + assert.strictEqual(revived.runs[0]?.abandoned, true) + assert.isUndefined(revived.runs[0]?.resume) + assert.strictEqual(revived.runs[0]?.messageId, orphaned) + assert.strictEqual(revived.runs[0]?.sessionId, "org_test:prr-reply_1") + }) + + /** A person writing into a reply's session retries their own message, like any chat turn. */ + it("does not record a person's evicted turn on a reply session as abandoned", () => { + const { session, state } = recording() + session.beginTurn({ + sessionId: "org_test:prr-reply_1", + messageId: "u1", + text: "and the other file?", + tenant: TENANT, + origin: APP, + }) + + const revived = recording(state) + revived.session.alarm() + + assert.isFalse(revived.session.running()) + assert.lengthOf(revived.runs, 0) + }) + /** A person's chat turn is theirs to retry: released, not re-run. */ it("does not start an evicted chat turn again", () => { const { session, state } = recording() diff --git a/apps/ai/src/chat/ChatSession.ts b/apps/ai/src/chat/ChatSession.ts index 2cf1da5bdb..ec87a868e7 100644 --- a/apps/ai/src/chat/ChatSession.ts +++ b/apps/ai/src/chat/ChatSession.ts @@ -1,8 +1,7 @@ /** * `ChatSession` — the durable transcript, one Durable Object per `":"`. * - * Replaces Flue's `FlueMapleChatAgent` + `FlueRegistry` SQLite Durable Objects. The DO owns four - * things: + * The DO owns four things: * * 1. **Ordering.** It assigns every event a monotonic `seq`. That is what makes the client * transport resumable by construction: reconnect is `?cursor=`, not a @@ -28,14 +27,14 @@ * * Startup-CPU note (Cloudflare error 10021): this class is reachable from `worker.ts`, whose * module scope Cloudflare evaluates during upload validation. It must therefore import nothing - * from the app service graph at module scope — hence `@maple/domain` (the wire contract and its - * transcript fold) being the only import, and `./turn-runner` (which pulls in the whole graph) - * arriving through a dynamic import inside the method that needs it, exactly as `worker.ts` does + * from the app service graph at module scope: only the wire contract, its transcript fold and + * the Worker's binding ports are imported, and `./turn-runner` (which pulls in the whole graph) + * arrives through a dynamic import inside the method that needs it, exactly as `worker.ts` does * for the route graph. */ import * as Cloudflare from "@alchemy.run/cloudflare" import * as SecureRandom from "effect/crypto/SecureRandom" -import { Effect, FileSystem, Option, Path, Schema, Array as Arr, Record as Rec } from "effect" +import { Effect, FileSystem, Layer, Option, Path, Schema, Array as Arr, Record as Rec } from "effect" import { ChatTurnOrigin as ChatTurnOriginSchema, ChatTurnTenant, @@ -49,6 +48,7 @@ import { type ChatProposalSettlement, type ChatTurnOrigin, type ChatTurnTenantEncoded, + prReplyIdFromChatSessionId, prReviewIdFromChatSessionId, } from "@maple/domain/chat-session" import { type ChatAbortReason, type ChatSessionRpc, isChatAbortReason } from "@maple/domain/chat-session-stub" @@ -56,6 +56,7 @@ import { type ChatSessionNamespace, ChatSessionObject } from "@maple/backend/pla import { makeChatTranscript } from "@maple/domain/chat-transcript" import type { AppliedProposal, ApplyChatProposalInput } from "./apply-proposal" import { WorkersAiGateway } from "../platform/WorkersAiHttpClient" +import { aiSessionPorts, BoundAiClients } from "../worker/bindings" import type { RunChatSessionTurnInput } from "./turn-runner" /** What the class reads off its Durable Object state: SQLite, the alarm, and the object's own `waitUntil`. */ @@ -227,6 +228,14 @@ const encodeTurnInput = Schema.encodeSync(Schema.fromJsonString(TurnInput)) const resumable = (input: TurnInput): boolean => input.origin.kind === "autonomous" && prReviewIdFromChatSessionId(input.sessionId) !== undefined +/** + * Turns whose rows nothing else settles once the fiber is gone: a review past its resumes, and a + * pull request reply, which is not started again because a second run could answer twice. + */ +const settlesWhenAbandoned = (input: TurnInput): boolean => + resumable(input) || + (input.origin.kind === "autonomous" && prReplyIdFromChatSessionId(input.sessionId) !== undefined) + /** How the session runs a turn: a port for the same reason as {@link ProposalApplier}. */ export type TurnRunner = (input: RunChatSessionTurnInput) => Promise @@ -728,8 +737,8 @@ export class ChatSession { * * An autonomous review is started again under a new turn id, from the kickoff `beginTurn` * stored, up to {@link MAX_TURN_RESUMES} times. Past that, or for any other turn, the slot is - * released; a review is also told it failed, since nothing else would move its row off - * `running` and its check run off in-progress. + * released; a review or a pull request reply is also told it failed, since nothing else would + * move its row off `running`, its check run off in-progress, or tell the pull request. */ private recoverEvictedTurn(messageId: string): void { const row = this.sessionRow() @@ -743,7 +752,7 @@ export class ChatSession { return } if (!this.endTurn(messageId, TURN_FAILED)) return - if (Option.isSome(turn) && resumable(turn.value)) { + if (Option.isSome(turn) && settlesWhenAbandoned(turn.value)) { const input = { ...this.turnRunInput(messageId, turn.value), abandoned: true as const } this.ctx.waitUntil( Effect.runPromise( @@ -897,16 +906,23 @@ export interface ChatSessionSchema { */ export const makeChatSessionActivation = ( migrations: Effect.Effect, never, R>, + ports: { readonly apply: ProposalApplier; readonly run: TurnRunner } = { + apply: applyThroughWorker, + run: runThroughWorker, + }, ) => Effect.map( Effect.all([ Cloudflare.DurableObjectState, Cloudflare.WorkerEnvironment, WorkersAiGateway, + BoundAiClients, Effect.serviceOption(Cloudflare.DurableObjectScope), migrations, ]), - ([state, env, workersAi, scope, schema]) => { + ([state, env, workersAi, clients, scope, schema]) => { + // The limiters and the audit queue the Worker bound: a turn's runtime has no init to read them from. + const bindingPorts = aiSessionPorts(clients) // Always there in the isolate; absent only where a test builds the activation by hand. const chatSessions: ChatSessionNamespace | undefined = Option.getOrUndefined( Option.filter(scope, isChatSessionNamespace), @@ -919,8 +935,8 @@ export const makeChatSessionActivation = ( new ChatSession( state.raw, env, - (input) => applyThroughWorker({ ...input, chatSessions }), - (input) => runThroughWorker({ ...input, workersAi, chatSessions }), + (input) => ports.apply({ ...input, chatSessions, bindingPorts }), + (input) => ports.run({ ...input, workersAi, chatSessions, bindingPorts }), ), ) }) @@ -940,6 +956,8 @@ export const ChatSessionLive = ChatSessionObject.make< | Cloudflare.WorkerEnvironment | Cloudflare.Worker | WorkersAiGateway + | BoundAiClients | FileSystem.FileSystem | Path.Path ->(activateChatSession) + // The same layer value the init's bindings hold, so the clients are bound once. +>(activateChatSession).pipe(Layer.provide(BoundAiClients.layer)) diff --git a/apps/ai/src/chat/ChatSessionObject.test.ts b/apps/ai/src/chat/ChatSessionObject.test.ts index 35eedd55b3..7b1f0ff323 100644 --- a/apps/ai/src/chat/ChatSessionObject.test.ts +++ b/apps/ai/src/chat/ChatSessionObject.test.ts @@ -1,10 +1,38 @@ import { assert, describe, it } from "@effect/vitest" import { encodeChatTurnTenant, type ChatTurnTenant } from "@maple/domain/chat-session" import * as Cloudflare from "@alchemy.run/cloudflare" -import { Effect, Layer, Option, Array as Arr } from "effect" +import { Context, Effect, Layer, Option, Array as Arr } from "effect" import { applyChatSessionMigrations, makeFakeDurableObjectState } from "../../test/chat/fake-do-state" +import { + AuditEventsQueueProducer, + DataSourceDevelopmentCallLimit, + DataSourceProductionCallLimit, +} from "@maple/backend/platform/bindings" import { WorkersAiGateway } from "../platform/WorkersAiHttpClient" -import { makeChatSessionActivation } from "./ChatSession" +import { BoundAiClients } from "../worker/bindings" +import { makeChatSessionActivation, type ProposalApplier, type TurnRunner } from "./ChatSession" +import type { ApplyChatProposalInput } from "./apply-proposal" +import type { RunChatSessionTurnInput } from "./turn-runner" +import { provideSessionPorts } from "./session-ports" + +/** Reads the ports the way the budget and audit services do: as they are built, and only if they are there. */ +class OptionalPorts extends Context.Service< + OptionalPorts, + { + readonly production: Option.Option + readonly development: Option.Option + readonly auditQueue: Option.Option + } +>()("@maple/ai/test/OptionalPorts") { + static readonly layer = Layer.effect( + this, + Effect.all({ + production: Effect.serviceOption(DataSourceProductionCallLimit), + development: Effect.serviceOption(DataSourceDevelopmentCallLimit), + auditQueue: Effect.serviceOption(AuditEventsQueueProducer), + }), + ) +} const TENANT = encodeChatTurnTenant({ orgId: "org_test" as ChatTurnTenant["orgId"], @@ -13,25 +41,63 @@ const TENANT = encodeChatTurnTenant({ authMode: "self_hosted", }) +/** The clients the Worker bound, recording what reaches them: every limiter refuses, the queue accepts. */ +const makeBoundClients = () => { + const limited: Array = [] + const queued: Array = [] + const limiter = (name: string) => ({ + limit: (options: { readonly key: string }) => + Effect.sync(() => { + limited.push(`${name}:${options.key}`) + return { success: false } + }), + }) + const clients = { + mcpToolsRateLimit: limiter("mcp"), + dataSourceProductionCallLimit: limiter("production"), + dataSourceDevelopmentCallLimit: limiter("development"), + auditEvents: { + raw: Effect.succeed({ + sendBatch: (messages: ReadonlyArray<{ readonly body: unknown }>) => { + queued.push(...Arr.map(messages, (message) => message.body)) + return Promise.resolve() + }, + }), + }, + } + // SAFETY: the ports read `limit` off a limiter and `raw.sendBatch` off the queue, and nothing else. + const layer = Layer.succeed(BoundAiClients, clients as unknown as typeof BoundAiClients.Service) + return { layer, limited, queued } +} + /** Alchemy's `SqlMigrations` captures the files at deploy; a test applies the same files directly. */ -const activateChatSession = Effect.flatMap(Cloudflare.DurableObjectState, (state) => - makeChatSessionActivation( - Effect.succeed({ - apply: () => Effect.sync(() => applyChatSessionMigrations(state.raw.storage.sql)), - }), - ), -) +const activateChatSession = (ports?: { readonly apply: ProposalApplier; readonly run: TurnRunner }) => + Effect.flatMap(Cloudflare.DurableObjectState, (state) => + makeChatSessionActivation( + Effect.succeed({ + apply: () => Effect.sync(() => applyChatSessionMigrations(state.raw.storage.sql)), + }), + ports, + ), + ) /** One activation the way alchemy's Durable Object bridge performs it: the outer phase under the state, then the inner. */ -const activateOn = (state: ReturnType) => +const activateOn = ( + state: ReturnType, + options: { + readonly clients?: ReturnType + readonly ports?: { readonly apply: ProposalApplier; readonly run: TurnRunner } + } = {}, +) => Effect.gen(function* () { // SAFETY: the fake carries the `storage.sql` and `waitUntil` the class reads, and nothing else. const raw = state as unknown as import("@cloudflare/workers-types").DurableObjectState - const build = yield* activateChatSession.pipe( + const build = yield* activateChatSession(options.ports).pipe( Effect.provide([ Layer.succeed(Cloudflare.DurableObjectState, Cloudflare.fromDurableObjectState(raw)), Layer.succeed(Cloudflare.WorkerEnvironment, {}), Layer.succeed(WorkersAiGateway, Option.none()), + (options.clients ?? makeBoundClients()).layer, ]), ) return { rpc: yield* build, state } @@ -46,11 +112,12 @@ describe("the ChatSession Durable Object on alchemy's form", () => { // alchemy evaluates the outer Effect at plan time with `{ storage: {} }` as the state. // SAFETY: that is alchemy's own mock, reproduced here. const mock = { storage: {} } as unknown as import("@cloudflare/workers-types").DurableObjectState - const build = yield* activateChatSession.pipe( + const build = yield* activateChatSession().pipe( Effect.provide([ Layer.succeed(Cloudflare.DurableObjectState, Cloudflare.fromDurableObjectState(mock)), Layer.succeed(Cloudflare.WorkerEnvironment, {}), Layer.succeed(WorkersAiGateway, Option.none()), + makeBoundClients().layer, ]), ) assert.isTrue(Effect.isEffect(build)) @@ -115,4 +182,107 @@ describe("the ChatSession Durable Object on alchemy's form", () => { ]) }), ) + + /** A turn or an approval builds its own runtime, so the Worker's limiters and queue are handed to it. */ + describe("hands the Worker's bound clients to the runtimes it starts", () => { + const recordingPorts = () => { + const runs: Array = [] + const applies: Array = [] + return { + runs, + applies, + ports: { + run: (input: RunChatSessionTurnInput) => { + runs.push(input) + return Promise.resolve() + }, + apply: (input: ApplyChatProposalInput) => { + applies.push(input) + return Promise.resolve({ output: "Created alert rule ar_1.", isError: false }) + }, + }, + } + } + + it.effect("a turn's runtime is handed the bound data-source limiters", () => + Effect.gen(function* () { + const clients = makeBoundClients() + const recorded = recordingPorts() + const { rpc, state } = yield* activateOn(makeFakeDurableObjectState({ migrated: false }), { + clients, + ports: recorded.ports, + }) + yield* rpc.beginTurn({ + sessionId: "org_test:tab", + messageId: "m1", + text: "how many users signed up today?", + tenant: TENANT, + origin: { kind: "app" }, + }) + yield* Effect.promise(() => Promise.all(state.pending)) + + const bindingPorts = recorded.runs[0]?.bindingPorts + assert.isDefined(bindingPorts) + if (bindingPorts === undefined) return + // Read as the runtime reads them: by a layer built above the ports, optionally. + const outcomes = yield* Effect.gen(function* () { + const { production, development } = yield* OptionalPorts + if (Option.isNone(production) || Option.isNone(development)) return [] + return [yield* production.value.limit("src_1"), yield* development.value.limit("src_2")] + }).pipe(Effect.provide(OptionalPorts.layer.pipe(provideSessionPorts(bindingPorts)))) + assert.deepStrictEqual(outcomes, [{ success: false }, { success: false }]) + assert.deepStrictEqual(clients.limited, ["production:src_1", "development:src_2"]) + }), + ) + + it.effect("an approval's runtime is handed the bound audit queue", () => + Effect.gen(function* () { + const clients = makeBoundClients() + const recorded = recordingPorts() + const { rpc } = yield* activateOn(makeFakeDurableObjectState({ migrated: false }), { + clients, + ports: recorded.ports, + }) + yield* rpc.append({ type: "turn-start", messageId: "a1" }) + yield* rpc.append({ + type: "tool-call", + messageId: "a1", + callId: "call_9", + name: "create_alert_rule", + input: { name: "checkout p95" }, + proposed: true, + }) + yield* rpc.append({ type: "turn-end", messageId: "a1", reason: "stop" }) + yield* rpc.settleProposal({ + sessionId: "org_test:tab", + toolCallId: "call_9", + decision: "approve", + approver: { kind: "app" }, + tenant: TENANT, + }) + + const bindingPorts = recorded.applies[0]?.bindingPorts + assert.isDefined(bindingPorts) + if (bindingPorts === undefined) return + yield* Effect.gen(function* () { + const { auditQueue } = yield* OptionalPorts + if (Option.isSome(auditQueue)) + yield* auditQueue.value.sendBatch([{ body: { action: "alert_rule.create" } }]) + }).pipe(Effect.provide(OptionalPorts.layer.pipe(provideSessionPorts(bindingPorts)))) + assert.deepStrictEqual(clients.queued, [{ action: "alert_rule.create" }]) + }), + ) + + it.effect("a runtime built without ports gets none of them instead of failing", () => + Effect.gen(function* () { + const { production, development, auditQueue } = yield* OptionalPorts.pipe( + Effect.provide(OptionalPorts.layer.pipe(provideSessionPorts(undefined))), + ) + assert.deepStrictEqual( + [Option.isNone(production), Option.isNone(development), Option.isNone(auditQueue)], + [true, true, true], + ) + }), + ) + }) }) diff --git a/apps/ai/src/chat/agents.ts b/apps/ai/src/chat/agents.ts index 95551c2be5..74a4d5b463 100644 --- a/apps/ai/src/chat/agents.ts +++ b/apps/ai/src/chat/agents.ts @@ -24,8 +24,6 @@ import * as Output from "@yielded/agent/output" import { Schema } from "effect" import type { Toolkit } from "effect/ai" import { chatModeFromSessionId, type ChatMode } from "@maple/domain/chat-session" -// The specific file, not the `./loop` barrel: the barrel re-exports `turn.ts`, which imports this -// module back. `budgets.ts` depends on nothing but `effect`. import { type AgentBudget, CHAT_BUDGET, @@ -54,7 +52,7 @@ export interface AgentDefinition { readonly permission: PermissionRuleset /** * The narrower ruleset the agent's *unattended* pass runs under, when it has one of its own. - * Without it an autonomous turn runs read-only; see `rulesetForTurn`. + * Without it an autonomous turn runs read-only; see `profileForTurn`. */ readonly autonomousPermission?: PermissionRuleset /** diff --git a/apps/ai/src/chat/apply-proposal.ts b/apps/ai/src/chat/apply-proposal.ts index 04f2553308..c6013ac494 100644 --- a/apps/ai/src/chat/apply-proposal.ts +++ b/apps/ai/src/chat/apply-proposal.ts @@ -26,6 +26,8 @@ import { runtimeTelemetry } from "@maple/infra/worker-telemetry" import { Cause, Effect, Layer, ManagedRuntime, Option, Schema, Array as Arr } from "effect" import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "../mcp/expected-failures" import { MUTATING_TOOL_NAMES } from "../mcp/tools/mutating" +import type { AiSessionPortsLayer } from "../worker/bindings" +import { provideSessionPorts } from "./session-ports" import { linkedUserTenant, toTenantContext, withConnectorActor } from "./turn-actor" /** Disposing the proposal's runtime or flushing its spans rejected; both are best effort. */ @@ -47,6 +49,8 @@ export type ApplyChatProposalInput = ChatProposalApproval & { readonly env: Record /** This Worker's `ChatSession` namespace, from the activation; the graph's `ChatSessions` port. */ readonly chatSessions?: ChatSessionNamespace + /** The data-source call limiters and the audit queue, from the activation: the tool's audit entry is queued. */ + readonly bindingPorts?: AiSessionPortsLayer /** `":"` — the org the change is made in. */ readonly sessionId: string /** The tool and arguments the session read out of its own log, never off the wire. */ @@ -142,6 +146,7 @@ export const applyChatProposal = async (input: ApplyChatProposalInput): Promise< Layer.provideMerge(layerMapleDbConnection(input.env)), Layer.provideMerge(envPorts(input.env)), Layer.provideMerge(chatSessionsLayerIfBound(input.chatSessions, input.env)), + provideSessionPorts(input.bindingPorts), Layer.provideMerge(telemetry.layer), ), ) diff --git a/apps/ai/src/chat/autonomous-settle.test.ts b/apps/ai/src/chat/autonomous-settle.test.ts index 026f86d6fc..81aef4023d 100644 --- a/apps/ai/src/chat/autonomous-settle.test.ts +++ b/apps/ai/src/chat/autonomous-settle.test.ts @@ -3,7 +3,14 @@ import type { PrReviewFailureReason } from "@maple/domain/http" import { Deferred, Effect, Fiber } from "effect" import { assert, describe, it } from "vitest" import { makeAutonomousOutcome } from "./autonomous-outcome" -import { NO_DIAGNOSIS_INTERRUPTED, makeUnfinishedSettler, runAutonomousTurn } from "./autonomous-settle" +import { + NO_DIAGNOSIS_INTERRUPTED, + makeUnfinishedSettler, + runAutonomousTurn, + NO_REPLY_INTERRUPTED, + settleAbandoned, + settleAbandonedTurn, +} from "./autonomous-settle" import { interruptOnAbort, makeTurnObservability } from "./turn-outcome" /** A turn that owns all three rows, recording every write that marks one failed. */ @@ -121,3 +128,83 @@ describe("runAutonomousTurn", () => { assert.deepEqual(turn.writes, ["settled"]) }) }) + +describe("settleAbandoned", () => { + it("fails the reply row of an evicted reply turn", async () => { + const writes: Array = [] + await Effect.runPromise(settleAbandoned({ failReply: Effect.sync(() => void writes.push("reply")) })) + + assert.deepEqual(writes, ["reply"]) + }) + + it("still fails the reply when the review's write fails", async () => { + const writes: Array = [] + await Effect.runPromise( + settleAbandoned({ + failReview: Effect.fail("the database is away"), + failReply: Effect.sync(() => void writes.push("reply")), + }), + ) + + assert.deepEqual(writes, ["reply"]) + }) +}) + +describe("settleAbandonedTurn", () => { + const calls = () => { + const made: Array> = [] + const record = + (write: string) => + (...args: ReadonlyArray) => + Effect.sync(() => void made.push([write, ...args])) + return { made, failReview: record("failReview"), failReply: record("failReply") } + } + + it("fails an evicted reply turn's row once, with the interrupted message, and nothing else", async () => { + const { made, failReview, failReply } = calls() + await Effect.runPromise( + settleAbandonedTurn({ + orgId: "org_1", + prReviewId: undefined, + prReplyId: "reply_1", + reviewInterrupted: "review interrupted", + failReview, + failReply, + }), + ) + + assert.deepEqual(made, [["failReply", "org_1", "reply_1", NO_REPLY_INTERRUPTED]]) + }) + + it("fails an evicted review turn's row with the interrupted review error", async () => { + const { made, failReview, failReply } = calls() + await Effect.runPromise( + settleAbandonedTurn({ + orgId: "org_1", + prReviewId: "review_1", + prReplyId: undefined, + reviewInterrupted: "review interrupted", + failReview, + failReply, + }), + ) + + assert.deepEqual(made, [["failReview", "org_1", "review_1", "review interrupted"]]) + }) + + it("writes nothing for a turn that owns neither row", async () => { + const { made, failReview, failReply } = calls() + await Effect.runPromise( + settleAbandonedTurn({ + orgId: "org_1", + prReviewId: undefined, + prReplyId: undefined, + reviewInterrupted: "review interrupted", + failReview, + failReply, + }), + ) + + assert.deepEqual(made, []) + }) +}) diff --git a/apps/ai/src/chat/autonomous-settle.ts b/apps/ai/src/chat/autonomous-settle.ts index 491738b5b7..12574dac16 100644 --- a/apps/ai/src/chat/autonomous-settle.ts +++ b/apps/ai/src/chat/autonomous-settle.ts @@ -10,6 +10,8 @@ import { abortReason, type TurnObservability } from "./turn-outcome" /** What the investigation row records when the turn was stopped before its report. */ export const NO_DIAGNOSIS_INTERRUPTED = "interrupted: the run was stopped before it submitted a diagnosis; retry" +/** What the reply row records when its turn was evicted: a reply is never started again. */ +export const NO_REPLY_INTERRUPTED = "interrupted: the run was restarted before it submitted an answer" /** How long a row write that settles an unfinished turn may take; it can run in a finalizer. */ const ROW_WRITE_TIMEOUT = "10 seconds" @@ -26,6 +28,42 @@ export const settleRow = (write: Effect.Effect, failed: string) => Effect.catchCause((cause) => Effect.logError(failed, cause)), ) +/** + * A turn the session gave up after an eviction. Nothing runs: the rows it owned are marked failed, + * which is also what tells the pull request, since the dead fiber's own settling never ran. + */ +export const settleAbandoned = (rows: { + readonly failReply?: Effect.Effect + readonly failReview?: Effect.Effect +}) => + Effect.gen(function* () { + if (rows.failReview !== undefined) + yield* settleRow(rows.failReview, "Could not record the abandoned review") + if (rows.failReply !== undefined) + yield* settleRow(rows.failReply, "Could not record the abandoned reply") + }) + +/** + * `settleAbandoned` for the rows a turn names: its review, its reply, or neither. Apart from the + * runner so the choice of write, and what each is told, is testable without a turn's runtime. + */ +export const settleAbandonedTurn = (turn: { + readonly orgId: Org + readonly prReviewId: ReviewId | undefined + readonly prReplyId: ReplyId | undefined + readonly reviewInterrupted: ReviewError + readonly failReview: (orgId: Org, id: ReviewId, error: ReviewError) => Effect.Effect + readonly failReply: (orgId: Org, id: ReplyId, error: string) => Effect.Effect +}) => + settleAbandoned({ + ...(turn.prReviewId === undefined + ? undefined + : { failReview: turn.failReview(turn.orgId, turn.prReviewId, turn.reviewInterrupted) }), + ...(turn.prReplyId === undefined + ? undefined + : { failReply: turn.failReply(turn.orgId, turn.prReplyId, NO_REPLY_INTERRUPTED) }), + }) + export const makeUnfinishedSettler = (options: { readonly outcome: AutonomousOutcome readonly signal: AbortSignal | undefined diff --git a/apps/ai/src/chat/permissions.ts b/apps/ai/src/chat/permissions.ts index 357650abe8..f5be53672e 100644 --- a/apps/ai/src/chat/permissions.ts +++ b/apps/ai/src/chat/permissions.ts @@ -3,7 +3,7 @@ * * `MUTATING_TOOL_NAMES` stays exactly where it is and keeps its shape: it seeds `DEFAULT_RULESET` * here, and it remains the allowlist floor for applying an approval. The equivalence is pinned - * by a test in `apps/api/src/mcp/tools/mutating.test.ts` so day-one behaviour cannot drift by + * by a test in `../mcp/tools/mutating.test.ts` so day-one behaviour cannot drift by * accident. */ import { PermissionRule, type PermissionRuleset } from "@maple/domain/permission" @@ -80,13 +80,10 @@ export const PR_REVIEW_TOOLS: ReadonlyArray = [ "error_detail", ] -/** A reply reads what a review reads; its only writes are its own completion tools. */ -export const PR_REPLY_RULESET: PermissionRuleset = [ - new PermissionRule({ tool: "*", action: "deny" }), - ...Arr.map([...PR_REVIEW_TOOLS].sort(), (tool) => new PermissionRule({ tool, action: "allow" })), -] - export const PR_REVIEW_RULESET: PermissionRuleset = [ new PermissionRule({ tool: "*", action: "deny" }), ...Arr.map([...PR_REVIEW_TOOLS].sort(), (tool) => new PermissionRule({ tool, action: "allow" })), ] + +/** A reply reads what a review reads; its only writes are its own completion tools. */ +export const PR_REPLY_RULESET: PermissionRuleset = PR_REVIEW_RULESET diff --git a/apps/ai/src/chat/prompts.ts b/apps/ai/src/chat/prompts.ts index e6aaf1fd09..4b76b49d46 100644 --- a/apps/ai/src/chat/prompts.ts +++ b/apps/ai/src/chat/prompts.ts @@ -1,4 +1,4 @@ -// System prompts ported from apps/chat-agent/src/services/system-prompt.ts. +// The chat agents' system prompts. // // Tools are named exactly as the toolkit registers them (`find_errors`), so the prompts use those // names directly. Mutating tools follow the propose-then-apply pattern (see agents.ts / the diff --git a/apps/ai/src/chat/session-ports.ts b/apps/ai/src/chat/session-ports.ts new file mode 100644 index 0000000000..a9c7471797 --- /dev/null +++ b/apps/ai/src/chat/session-ports.ts @@ -0,0 +1,10 @@ +/** + * How a Durable Object runtime takes the Worker's bound ports. A turn or an approval builds its + * own runtime outside any request, so the limiters and the audit queue reach it only this way. + */ +import { Layer } from "effect" +import type { AiSessionPortsLayer } from "../worker/bindings" + +/** Puts the session's ports under a runtime's graph; a caller with none (a local script) adds nothing. */ +export const provideSessionPorts = (ports: AiSessionPortsLayer | undefined) => + Layer.provideMerge(ports ?? Layer.empty) diff --git a/apps/ai/src/chat/tools.ts b/apps/ai/src/chat/tools.ts index 7757b47a6c..ec43d5c469 100644 --- a/apps/ai/src/chat/tools.ts +++ b/apps/ai/src/chat/tools.ts @@ -1,7 +1,7 @@ /** * What a chat run is allowed to call. * - * Kept out of `loop/` on purpose: deciding *when* to call a tool is the engine's job, not this + * Kept out of the run loop (`run.ts`) on purpose: deciding *when* to call a tool is the engine's job, not this * module's. Swapping the tool set — a read-only sub-agent, a mode with narrower reach — should not * touch control flow at all. */ diff --git a/apps/ai/src/chat/turn-runner.ts b/apps/ai/src/chat/turn-runner.ts index 1eebfd16bd..b717283edc 100644 --- a/apps/ai/src/chat/turn-runner.ts +++ b/apps/ai/src/chat/turn-runner.ts @@ -42,6 +42,8 @@ import { } from "effect" import { FetchHttpClient } from "effect/http" import type { WorkersAiBinding } from "../platform/WorkersAiHttpClient" +import type { AiSessionPortsLayer } from "../worker/bindings" +import { provideSessionPorts } from "./session-ports" import { ReturnedToolFailuresOkLive } from "../platform/genai-spans" import type { ChatSession } from "./ChatSession" import { withToolTranscript } from "./close-out" @@ -70,7 +72,7 @@ import { import { interruptOnAbort, makeTurnObservability, turnSpanAttributes } from "./turn-outcome" import { makeAutonomousOutcome, type PhaseKind } from "./autonomous-outcome" -import { makeUnfinishedSettler, runAutonomousTurn, settleRow } from "./autonomous-settle" +import { makeUnfinishedSettler, runAutonomousTurn, settleAbandonedTurn, settleRow } from "./autonomous-settle" import { type AgentDefinition, agentForSession } from "./agents" import { profileForTurn } from "./profiles" import { runChatTurn } from "./run" @@ -125,6 +127,8 @@ export interface RunChatSessionTurnInput { readonly workersAi?: Option.Option /** This Worker's `ChatSession` namespace, from the activation; the graph's `ChatSessions` port. */ readonly chatSessions?: ChatSessionNamespace + /** The data-source call limiters and the audit queue, from the activation; none where a test runs the turn. */ + readonly bindingPorts?: AiSessionPortsLayer readonly messageId: string readonly tenant: ChatTurnTenantEncoded /** Who is driving the turn, stated by whoever raised it. */ @@ -320,6 +324,7 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis Layer.provideMerge(layerMapleDbConnection(input.env)), Layer.provideMerge(envPorts(input.env)), Layer.provideMerge(chatSessionsLayerIfBound(input.chatSessions, input.env)), + provideSessionPorts(input.bindingPorts), Layer.provideMerge(ReturnedToolFailuresOkLive.pipe(Layer.provideMerge(telemetry.layer))), ), ) @@ -363,15 +368,16 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis ), ) if (runTenant === undefined) return yield* annotateTurn() - if (prReviewId !== undefined && input.abandoned === true) { + if (input.abandoned === true) { observability.outcome = "abandoned" - yield* reviews - .failReview(tenant.orgId, prReviewId, reviewFailureError("interrupted")) - .pipe( - Effect.catchCause((cause) => - Effect.logError("Could not record the abandoned review", cause), - ), - ) + yield* settleAbandonedTurn({ + orgId: tenant.orgId, + prReviewId, + prReplyId, + reviewInterrupted: reviewFailureError("interrupted"), + failReview: (orgId, id, error) => reviews.failReview(orgId, id, error), + failReply: (orgId, id, error) => conversations.failReply(orgId, id, error), + }) yield* annotateTurn() return } @@ -445,7 +451,8 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis ), "Could not record the refused reply", ) - if (autonomous) + // A reply's row is settled above, with the refusal's own notice. + else if (autonomous) yield* failUnfinished("ai_credits_exhausted", creditRefusalError(refusal.value)) observability.failureReason = `AiCreditsExhausted:${refusal.value.reason}` input.session.endTurn(input.messageId, { reason: "error", error: refusal.value.message }) diff --git a/apps/ai/src/evals/tools/tasks.ts b/apps/ai/src/evals/tools/tasks.ts index e7d1243d2f..6e79cd1409 100644 --- a/apps/ai/src/evals/tools/tasks.ts +++ b/apps/ai/src/evals/tools/tasks.ts @@ -1,7 +1,7 @@ /** * Tool-use tasks: one user message to the production chat agent, graded on what the calls mean. * - * Ported tasks come from the old selection suites and `apps/cli/EVALS.md`; their targets now list + * Ported tasks come from the old selection suites and the CLI's former eval list; their targets now list * every acceptable answer instead of one exact argument object. New tasks are negative cases (no * tool fits, a read must not mutate, an ambiguous destructive request) and near-miss phrasings, * after BFCL's irrelevance category and MCP-Bench's fuzzy tasks. `tasks.test.ts` checks every @@ -139,7 +139,7 @@ export const TOOL_TASKS: ReadonlyArray = [ calls(call("service_map")), ), - // Investigation scenarios from apps/cli/EVALS.md. + // Investigation scenarios from the CLI's former eval list. regression( "top-operations-by-count", ["services", "analytics"], diff --git a/apps/ai/src/mcp/__evals__/eval-runtime.ts b/apps/ai/src/mcp/__evals__/eval-runtime.ts index bcb106a3ae..4c21d6f849 100644 --- a/apps/ai/src/mcp/__evals__/eval-runtime.ts +++ b/apps/ai/src/mcp/__evals__/eval-runtime.ts @@ -42,7 +42,7 @@ export interface EvalRuntime { /** * Build a node runtime for the app services backed by an in-memory PGlite DB + - * test config (mirrors apps/api `getMapleAgentSetup`/buildSetup, swapping Hyperdrive→PGlite). + * test config (the Worker's service graph, with PGlite in place of Hyperdrive). * The warehouse client must be faked separately via `installFakeWarehouse` — * this runtime uses the REAL WarehouseQueryService. The returned `requestLayer` * carries the already-resolved MCP tenant, matching the post-auth dispatcher @@ -72,8 +72,7 @@ export const makeEvalRuntime = ( const layer = services.pipe( Layer.provide(Layer.mergeAll(configLive, envLive, databaseLive, workerEnvLive)), ) - // `as any`: the residual requirement set is satisfied at runtime (same pattern - // as apps/api/src/agent.ts buildSetup). + // `as any`: the residual requirement set is satisfied at runtime. // The lint type checker reads `make(any)` as already ``; tsc infers ``. // oxlint-disable-next-line typescript/no-explicit-any, typescript/no-unnecessary-type-assertion const runtime = ManagedRuntime.make(layer as any) as ManagedRuntime.ManagedRuntime diff --git a/apps/ai/src/mcp/app.test.ts b/apps/ai/src/mcp/app.test.ts index 2704e309e9..174fc07dfb 100644 --- a/apps/ai/src/mcp/app.test.ts +++ b/apps/ai/src/mcp/app.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "@effect/vitest" -import { OrgId, UserId } from "@maple/domain/http" +import { AuthorizationUnavailableError, OrgId, UserId } from "@maple/domain/http" import { INVESTIGATION_READ_FAMILIES } from "@maple/domain/http/v2" import { ConfigProvider, @@ -126,11 +126,82 @@ describe("MCP HTTP authorization", () => { 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"', ) expect(response.headers.get("www-authenticate")).toContain('scope="mcp:tools"') + // No token was sent, so there is none to call invalid: the client starts OAuth, not a refresh. + expect(response.headers.get("www-authenticate")).not.toContain("invalid_token") } finally { await dispose() } }) + describe("the session fallback", () => { + /** One `initialize` against the server, with `auth` standing in for the session resolver. */ + const initialize = async ( + auth: Layer.Layer>, + headers: Record, + ) => { + const db = createTestDb(createdDbs) + const base = Layer.mergeAll(db.layer, Env.layer.pipe(Layer.provide(testConfig()))) + const services = Layer.mergeAll( + ApiKeysService.layer, + ErrorActorsService.layer, + auth, + makeMcpToolExecutorStubLayer(), + makeRateLimiterStubLayer(), + ).pipe(Layer.provideMerge(base)) + const { handler, dispose } = HttpRouter.toWebHandler(McpLive.pipe(Layer.provideMerge(services)), { + disableLogger: true, + }) + const response = await handler( + new Request("https://api.example.com/mcp", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + }), + }), + Context.empty(), + ) + await dispose() + return response + } + + /** The real service with its session lookup failing the way an auth provider outage does. */ + const authProviderAway = Layer.effect( + AuthService, + Effect.gen(function* () { + const auth = yield* AuthService + return { + ...auth, + resolveMcpTenant: () => + Effect.fail( + new AuthorizationUnavailableError({ message: "The auth provider is away" }), + ), + } + }), + ).pipe(Layer.provide(AuthService.layer)) + + it("answers 503, not a sign-in challenge, while the auth provider is away", async () => { + const response = await initialize(authProviderAway, { authorization: "Bearer aaa.bbb.ccc" }) + + expect(response.status).toBe(503) + expect(response.headers.get("www-authenticate")).toBeNull() + }) + + it("calls a session token nobody issued invalid", async () => { + const response = await initialize(AuthService.layer, { authorization: "Bearer aaa.bbb.ccc" }) + + expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toContain('error="invalid_token"') + }) + }) + it("accepts an audience-bound OAuth key behind a forwarded HTTPS proxy", async () => { const db = createTestDb(createdDbs) const base = Layer.mergeAll(db.layer, Env.layer.pipe(Layer.provide(testConfig()))) diff --git a/apps/ai/src/mcp/expected-failures.ts b/apps/ai/src/mcp/expected-failures.ts index badebe7eca..3a7dcd5afe 100644 --- a/apps/ai/src/mcp/expected-failures.ts +++ b/apps/ai/src/mcp/expected-failures.ts @@ -10,7 +10,7 @@ import { Effect, Record as Rec, String as Str } from "effect" * HTTP: the SDK's tracer exports a span whose failure is caused *entirely* by an * anticipated identifier as OTLP `Ok` with no `exception` event. That set is * derived by reflection over the domain HTTP exports and cannot see these - * classes — they live in `apps/api/src/mcp` — so they are listed here and spread + * classes (they live in `apps/ai/src/mcp`), so they are listed here and spread * into the runtime telemetry configs alongside it. * * `McpAuthUnavailableError` (503) and `McpQueryError` are deliberately absent: diff --git a/apps/ai/src/mcp/lib/resolve-tenant.ts b/apps/ai/src/mcp/lib/resolve-tenant.ts index 8389cc1b45..4073e28345 100644 --- a/apps/ai/src/mcp/lib/resolve-tenant.ts +++ b/apps/ai/src/mcp/lib/resolve-tenant.ts @@ -191,21 +191,49 @@ const resolveInternalServiceTenant = Effect.fn("McpTenant.resolveInternalService }) }) +/** A request with neither an `Authorization` header nor a cookie sent nothing a session reads. */ +const carriesCredential = (headers: Headers): boolean => headers.has("authorization") || headers.has("cookie") + +/** + * The session resolver's failures as the MCP ones. A provider outage is retryable (503), and a + * request that sent no credential is challenged without `invalid_token`: there was no token. + * Only on a refusal, because self-hosted auth can admit a request that carries nothing. + */ +const sessionAuthFailure = + (request: Request) => + ( + resolved: Effect.Effect>, R>, + ) => + resolved.pipe( + Effect.catchTags({ + "@maple/http/errors/AuthorizationUnavailableError": (error) => + Effect.fail(new McpAuthUnavailableError({ message: error.message })), + "@maple/http/errors/UnauthorizedError": (error) => + Effect.fail( + carriesCredential(request.headers) + ? new McpAuthInvalidError({ + message: error.message || "Authentication failed (no details available)", + reason: "session_auth_fallback", + }) + : new McpAuthMissingError({ message: "No credential was sent with the request" }), + ), + // The credential is good, the org is not theirs: signing in again is the way to pick another. + "@maple/http/errors/OrganizationAccessDeniedError": (error) => + Effect.fail( + new McpAuthInvalidError({ message: error.message, reason: "session_auth_fallback" }), + ), + }), + ) + /** * A signed-in person's session, never an API key or MCP OAuth token: `resolveTenant` accepts * session tokens only, unlike `resolveMcpTenant`. */ const resolveSessionTenant = Effect.fn("McpTenant.resolveSessionTenant")(function* (request: Request) { const auth = yield* AuthService - const tenant = yield* auth.resolveTenant(toHeaderRecord(request.headers)).pipe( - Effect.mapError( - (error) => - new McpAuthInvalidError({ - message: error.message || "Authentication failed (no details available)", - reason: "session_auth_fallback", - }), - ), - ) + const tenant = yield* auth + .resolveTenant(toHeaderRecord(request.headers)) + .pipe(sessionAuthFailure(request)) return { orgId: tenant.orgId, userId: tenant.userId, @@ -330,15 +358,9 @@ export const resolveMcpTenantContext = Effect.fn("McpTenant.resolveMcpTenantCont // Fall back to existing Clerk / self-hosted session auth const auth = yield* AuthService - const tenant = yield* auth.resolveMcpTenant(toHeaderRecord(request.headers)).pipe( - Effect.mapError( - (error) => - new McpAuthInvalidError({ - message: error.message || "Authentication failed (no details available)", - reason: "session_auth_fallback", - }), - ), - ) + const tenant = yield* auth + .resolveMcpTenant(toHeaderRecord(request.headers)) + .pipe(sessionAuthFailure(request)) return { orgId: tenant.orgId, diff --git a/apps/ai/src/mcp/lib/uptime-monitors.ts b/apps/ai/src/mcp/lib/uptime-monitors.ts index 20138125b5..2f1e95a227 100644 --- a/apps/ai/src/mcp/lib/uptime-monitors.ts +++ b/apps/ai/src/mcp/lib/uptime-monitors.ts @@ -137,7 +137,7 @@ export interface UptimeConfigFields { readonly expected?: string | undefined readonly heartbeat_interval_seconds?: number | undefined readonly cron?: string | undefined - readonly timezone?: string | undefined + readonly time_zone?: string | undefined readonly grace_seconds?: number | undefined /** Update only: drop the matching check or expected answer. */ readonly clear_max_response_ms?: boolean | undefined @@ -157,7 +157,7 @@ const FIELD_KINDS: ReadonlyArray (candidate) ──> issue in \`triage\` ## Attribute Filtering - Call \`explore_attributes\` before filtering by custom attributes -- Prefer service_name filters to narrow results before free-text search +- Prefer the \`service\` filter to narrow results before free-text search - Common span attributes: http.method, http.route, http.status_code, db.system - Common resource attributes: service.name, deployment.environment, service.version @@ -102,9 +102,9 @@ first occurrences ──> (candidate) ──> issue in \`triage\` - Histogram percentiles (p50/p95/p99) are not available for metrics in query_data; use avg, or source=traces p95_duration for request latency ## Pagination -- Tools that return lists support pagination via offset parameter -- Check the hasMore field in responses to know if more results exist -- Use nextOffset value to fetch the next page +- Most list and search tools page by \`offset\`: when more rows remain the response says so and gives the \`offset\` of the next page (\`nextOffset\`) +- \`list_error_issues\` and \`list_users\` page by \`cursor\` instead: pass the response's \`nextCursor\` back as \`cursor\`; it is absent on the last page +- A tool with neither parameter returns one capped page: raise \`limit\` up to the tool's maximum, or narrow the filters or the window where it has them. Rows past the cap cannot be paged to ## Tool Selection Guide - Error investigation: find_errors -> error_detail -> inspect_trace diff --git a/apps/ai/src/mcp/tool-descriptors.generated.ts b/apps/ai/src/mcp/tool-descriptors.generated.ts index 59808effb6..6a0ab762ff 100644 --- a/apps/ai/src/mcp/tool-descriptors.generated.ts +++ b/apps/ai/src/mcp/tool-descriptors.generated.ts @@ -39,7 +39,7 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"update_alert_rule","title":"Update Alert Rule","description":"Update an alert rule. Pass only the fields to change; the rest keep their current value (get_alert_rule shows it). rule_ids applies one change to many rules (e.g. add_destination_ids on every rule with rule_ids=[\"*\"]). Ids from list_alert_rules and list_alert_destinations.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID to update (use list_alert_rules to find IDs): its UUID or its REST id (alrt_\u2026)"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Apply the same change to several rules in one call instead of rule_id; [\"*\"] means every rule. name cannot be set in bulk. Each is its UUID or its REST id (alrt_\u2026)"},"name":{"type":"string","description":"New rule name"},"severity":{"type":"string","enum":["warning","critical"],"description":"Alert severity"},"threshold":{"type":"number","description":"Threshold value. E.g. 0.05 for 5% error rate, 1000 for 1s latency"},"window_minutes":{"type":"number","description":"Evaluation window in minutes, at most 1440"},"evaluation_interval_minutes":{"anyOf":[{"type":"number","enum":[1,5,10,15,30,60]},{"type":"string"},{"type":"string","enum":["auto"]}],"description":"Minutes between checks, at most window_minutes. A raw_query rule checked less often gets a longer query time limit: 5s under 5 min, 15s at 5, 25s at 10 or more. Omit to keep the current one; pass \"auto\" to derive it from the window (a fifth, 1 to 5). Any edit also clears a slowdown the scheduler applied."},"services":{"type":"array","items":{"type":"string"},"description":"Service scope (replaces the current one; an empty list means all services)"},"environments":{"type":"array","items":{"type":"string"},"description":"Deployment environments to scope the alert to (replaces the current scope; pass an empty list for all environments). Ignored for builder_query / raw_query."},"enabled":{"type":"boolean","description":"Whether the rule is enabled"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to notify, from list_alert_destinations; replaces the current ones. A rule needs at least one. Each is its UUID or its REST id (dest_\u2026)"},"add_destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to add, keeping the current ones. Each is its UUID or its REST id (dest_\u2026)"},"remove_destination_ids":{"type":"array","items":{"type":"string"},"description":"Destination IDs to remove, keeping the rest. Each is its UUID or its REST id (dest_\u2026)"},"signal_type":{"type":"string","enum":["error_rate","p95_latency","p99_latency","apdex","throughput","builder_query","raw_query","anomaly"],"description":"What the rule measures. builder_query takes query_builder_draft, raw_query takes raw_query_sql, anomaly pages on the anomaly detector's incidents."},"anomaly_signals":{"type":"array","items":{"type":"string","enum":["error_rate","latency_p95","throughput","error_spike","log_volume"]},"description":"signal_type=anomaly only: detector signals the rule pages on (empty or omitted: every signal). An anomaly rule takes no comparator, threshold or window: it opens when the anomaly detector opens an anomaly in its scope (services, environments, anomaly_signals) at or above its severity, and resolves when that anomaly does. Replaces the current list; an empty list means every signal."},"comparator":{"type":"string","enum":["gt","gte","lt","lte","eq","neq"],"description":"How the observed value is compared with threshold"},"group_by":{"type":"array","items":{"type":"string"},"description":"Dimensions to evaluate the alert per-group (replaces the current grouping; an empty list removes it). Built-in tokens: service.name, span.name, status.code, http.method, severity. Attribute keys: attr.."},"minimum_sample_count":{"type":"number","description":"Skip evaluation below this many samples in the window. For raw_query it sums the `samples` column; without one each returned row counts as 1."},"alert_on_no_data":{"type":"boolean","description":"Count a window with no data as a breach instead of skipping it. Not supported with group_by."},"consecutive_breaches":{"type":"number","description":"Consecutive breaches before alerting"},"consecutive_healthy":{"type":"number","description":"Consecutive healthy evaluations before resolving"},"renotify_interval_minutes":{"type":"number","description":"Re-notification interval in minutes"},"apdex_threshold_ms":{"type":"number","description":"Apdex threshold in milliseconds (for signal_type=apdex)"},"query_builder_draft":{"type":"string","contentMediaType":"application/json","contentSchema":{"anyOf":[{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["traces"]},"valueField":{"type":"string"}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["logs"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["metrics"]},"signalSource":{"type":"string","enum":["default","meter"]},"metricName":{"type":"string"},"metricType":{"type":"string","enum":["sum","gauge","histogram","exponential_histogram"]},"isMonotonic":{"type":"boolean"},"spaceAggregation":{"type":"string","enum":["sum","avg","min","max","count"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"hidden":{"type":"boolean"},"whereClause":{"type":"string"},"aggregation":{"type":"string"},"stepInterval":{"type":"string"},"orderByDirection":{"type":"string","enum":["desc","asc"]},"addOns":{"type":"object","properties":{"groupBy":{"type":"boolean"},"having":{"type":"boolean"},"orderBy":{"type":"boolean"},"limit":{"type":"boolean"},"legend":{"type":"boolean"}},"required":["groupBy","having","orderBy","limit","legend"],"additionalProperties":false},"groupBy":{"type":"array","items":{"type":"string"}},"having":{"type":"string"},"orderBy":{"type":"string"},"limit":{"type":"string"},"seriesLimit":{"type":"string"},"legend":{"type":"string"},"dataSource":{"type":"string","enum":["product_events"]}},"required":["id","name","aggregation","dataSource"],"additionalProperties":false}]},"description":"Query-builder draft, same shape as create_alert_rule (for signal_type=builder_query)"},"raw_query_sql":{"type":"string","description":"ClickHouse SQL returning a numeric `value` column (for signal_type=raw_query), at most 100 groups. Must reference $__orgFilter and $__timeFilter(col)."},"raw_query_reducer":{"type":"string","enum":["identity","sum","avg","max","min"],"description":"How to collapse raw_query result rows into one value."},"notification_title":{"type":"string","description":"Notification title template with {{ variable }} substitution; variables as in create_alert_rule"},"notification_body":{"type":"string","description":"Notification body template (Markdown) with {{ variable }} and {{#if key}}...{{/if}}"},"escalation_chain_id":{"type":"string","description":"Escalation chain an incident starts on top of the destinations (from list_escalation_chains), or \"none\" to detach. Applies to escalation_chain_tier; without it, an id sets the critical (or only) tier and \"none\" detaches every tier: its UUID or its REST id (esc_\u2026)"},"escalation_chain_tier":{"type":"string","enum":["warning","critical"],"description":"The tier escalation_chain_id sets or detaches, leaving the other tier's chain as it is"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"rule":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"severity":{"type":"string"},"serviceNames":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}},"anomalySignals":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"evaluationIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"slowedIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true},"rules":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"enabled":{"type":"boolean"},"severity":{"type":"string"},"serviceNames":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}},"anomalySignals":{"type":"array","items":{"type":"string"}},"signalType":{"type":"string"},"comparator":{"type":"string"},"threshold":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"evaluationIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"slowedIntervalMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}},"escalationChains":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string"},"chainId":{"type":"string"},"name":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["severity","chainId"],"additionalProperties":true}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","enabled","severity","serviceNames","environments","signalType","comparator","threshold","windowMinutes","evaluationIntervalMinutes","slowedIntervalMinutes","destinationIds","createdAt","updatedAt"],"additionalProperties":true}},"warnings":{"type":"array","items":{"type":"string"}},"evaluation":{"type":"object","properties":{"nextEvaluationAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"current":{"type":"array","items":{"type":"object","properties":{"groupKey":{"type":"string"},"window":{"type":"string"},"status":{"type":"string"},"skipReason":{"anyOf":[{"type":"string"},{"type":"null"}]},"value":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"sampleCount":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["groupKey","window","status","skipReason","value","sampleCount"],"additionalProperties":true}},"previewError":{"type":"string"},"previewSkipped":{"type":"string"}},"required":["nextEvaluationAt","current"],"additionalProperties":true}},"required":["rule"],"additionalProperties":true},"annotations":{"title":"Update Alert Rule","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"delete_alert_rule","title":"Delete Alert Rule","description":"Permanently delete an alert rule together with its incident history, delivery events and evaluation state. Irreversible; requires confirm=true. Ids from list_alert_rules.","inputSchema":{"type":"object","properties":{"rule_id":{"type":"string","description":"Alert rule ID: its UUID or its REST id (alrt_\u2026)"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["rule_id","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":true},"annotations":{"title":"Delete Alert Rule","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, {"name":"list_maintenance_windows","title":"List Maintenance Windows","description":"List maintenance windows: one-off or weekly periods that silence matching alert rules and can keep their hours out of anomaly baselines. Each occurrence shows up as an alert silence while it runs.","inputSchema":{"type":"object","properties":{"limit":{"type":"number","description":"Max windows to return (default 50, max 100)"},"offset":{"type":"number","description":"Rows to skip, for paging (default 0). Use the `next` call a truncated result gives."}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"windows":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"reason":{"anyOf":[{"type":"string"},{"type":"null"}]},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"schedule":{"type":"object","properties":{"type":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"timeZone":{"type":"string"},"days":{"type":"array","items":{"type":"string"}},"startTime":{"type":"string"},"durationMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["type"],"additionalProperties":true},"excludeFromBaselines":{"type":"boolean"},"enabled":{"type":"boolean"},"nextOccurrence":{"anyOf":[{"type":"object","properties":{"startsAt":{"type":"string"},"endsAt":{"type":"string"}},"required":["startsAt","endsAt"],"additionalProperties":true},{"type":"null"}]},"createdBy":{"type":"string"}},"required":["id","name","reason","matchers","schedule","excludeFromBaselines","enabled","nextOccurrence","createdBy"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"offset":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["windows","total","limit","offset","hasMore"],"additionalProperties":true},"annotations":{"title":"List Maintenance Windows","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"create_maintenance_window","title":"Create Maintenance Window","description":"Schedule a maintenance window: alert notifications for matching rules are held back during each occurrence (incidents still open and resolve). schedule_type=once takes starts_at (default now) and ends_at or duration_minutes (max 30 days); schedule_type=weekly takes days, start_time (HH:MM, local to time_zone) and duration_minutes (5 to 10080). Matchers AND together, values inside one OR; pass at least one. With services or environments, the window's hours are also left out of anomaly baselines unless exclude_from_baselines=false.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Short name, shown on the window and on the silences it writes"},"reason":{"type":"string","description":"Why the window exists"},"schedule_type":{"type":"string","enum":["once","weekly"],"description":"A single window, or one that repeats every week"},"starts_at":{"type":"string","description":"When a one-off window opens (default: now), UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"ends_at":{"type":"string","description":"When a one-off window closes, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"time_zone":{"type":"string","description":"IANA time zone a weekly start_time is read in, e.g. Europe/Berlin (default UTC)"},"days":{"type":"array","items":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"description":"Weekdays a weekly window opens on"},"start_time":{"type":"string","description":"Local start of a weekly window, HH:MM (24-hour)"},"duration_minutes":{"type":"number","description":"How long each occurrence lasts, in minutes"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Silence these alert rules (ids from list_alert_rules). Each is its UUID or its REST id (alrt_\u2026)"},"tags":{"type":"array","items":{"type":"string"},"description":"Silence rules carrying any of these tags"},"services":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these services"},"environments":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these environments"},"exclude_from_baselines":{"type":"boolean","description":"Leave the window's hours out of anomaly baselines (default true; needs services or environments)"}},"required":["name","schedule_type"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"window":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"reason":{"anyOf":[{"type":"string"},{"type":"null"}]},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"schedule":{"type":"object","properties":{"type":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"timeZone":{"type":"string"},"days":{"type":"array","items":{"type":"string"}},"startTime":{"type":"string"},"durationMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["type"],"additionalProperties":true},"excludeFromBaselines":{"type":"boolean"},"enabled":{"type":"boolean"},"nextOccurrence":{"anyOf":[{"type":"object","properties":{"startsAt":{"type":"string"},"endsAt":{"type":"string"}},"required":["startsAt","endsAt"],"additionalProperties":true},{"type":"null"}]},"createdBy":{"type":"string"}},"required":["id","name","reason","matchers","schedule","excludeFromBaselines","enabled","nextOccurrence","createdBy"],"additionalProperties":true}},"required":["window"],"additionalProperties":true},"annotations":{"title":"Create Maintenance Window","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, + {"name":"create_maintenance_window","title":"Create Maintenance Window","description":"Schedule a maintenance window: alert notifications for matching rules are held back during each occurrence (incidents still open and resolve). schedule_type=once takes starts_at (default now) and ends_at or duration_minutes (max 30 days); schedule_type=weekly takes days, start_time_of_day (HH:MM, local to time_zone) and duration_minutes (5 to 10080). Matchers AND together, values inside one OR; pass at least one. With services or environments, the window's hours are also left out of anomaly baselines unless exclude_from_baselines=false.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Short name, shown on the window and on the silences it writes"},"reason":{"type":"string","description":"Why the window exists"},"schedule_type":{"type":"string","enum":["once","weekly"],"description":"A single window, or one that repeats every week"},"starts_at":{"type":"string","description":"When a one-off window opens (default: now), UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"ends_at":{"type":"string","description":"When a one-off window closes, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"time_zone":{"type":"string","description":"IANA time zone a weekly start_time_of_day is read in, e.g. Europe/Berlin (default UTC)"},"days":{"type":"array","items":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"description":"Weekdays a weekly window opens on"},"start_time_of_day":{"type":"string","description":"Local start of a weekly window, HH:MM (24-hour)"},"duration_minutes":{"type":"number","description":"How long each occurrence lasts, in minutes"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Silence these alert rules (ids from list_alert_rules). Each is its UUID or its REST id (alrt_\u2026)"},"tags":{"type":"array","items":{"type":"string"},"description":"Silence rules carrying any of these tags"},"services":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these services"},"environments":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these environments"},"exclude_from_baselines":{"type":"boolean","description":"Leave the window's hours out of anomaly baselines (default true; needs services or environments)"}},"required":["name","schedule_type"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"window":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"reason":{"anyOf":[{"type":"string"},{"type":"null"}]},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"schedule":{"type":"object","properties":{"type":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"timeZone":{"type":"string"},"days":{"type":"array","items":{"type":"string"}},"startTime":{"type":"string"},"durationMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["type"],"additionalProperties":true},"excludeFromBaselines":{"type":"boolean"},"enabled":{"type":"boolean"},"nextOccurrence":{"anyOf":[{"type":"object","properties":{"startsAt":{"type":"string"},"endsAt":{"type":"string"}},"required":["startsAt","endsAt"],"additionalProperties":true},{"type":"null"}]},"createdBy":{"type":"string"}},"required":["id","name","reason","matchers","schedule","excludeFromBaselines","enabled","nextOccurrence","createdBy"],"additionalProperties":true}},"required":["window"],"additionalProperties":true},"annotations":{"title":"Create Maintenance Window","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, {"name":"delete_maintenance_window","title":"Delete Maintenance Window","description":"Delete a maintenance window. An occurrence in progress ends now (notifications it held back go out), and occurrences ahead are dropped. Requires confirm=true. Ids from list_maintenance_windows.","inputSchema":{"type":"object","properties":{"maintenance_window_id":{"type":"string","description":"Maintenance window to delete: its UUID or its REST id (mw_\u2026)"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["maintenance_window_id","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":true},"annotations":{"title":"Delete Maintenance Window","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, {"name":"list_alert_silences","title":"List Alert Silences","description":"List alert silences: windows that hold back alert notifications for matching rules (incidents still open and resolve). Defaults to active and scheduled silences; pass status=ended for past ones.","inputSchema":{"type":"object","properties":{"status":{"type":"string","enum":["scheduled","active","ended"],"description":"Only silences in this status (default: active and scheduled)"},"limit":{"type":"number","description":"Max silences to return (default 50, max 200)"},"offset":{"type":"number","description":"Rows to skip, for paging (default 0). Use the `next` call a truncated result gives."}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silences":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"status":{"type":"string"},"limit":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"offset":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"hasMore":{"type":"boolean"}},"required":["silences","total","limit","hasMore"],"additionalProperties":true},"annotations":{"title":"List Alert Silences","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"create_alert_silence","title":"Create Alert Silence","description":"Hold back alert notifications for matching rules during a window (maintenance, a known outage). Incidents still open and resolve; only delivery is held. Matchers AND together, values inside one OR; pass at least one. services/environments must cover a rule's whole scope or the incident's group. Pass duration_minutes or ends_at (max 30 days).","inputSchema":{"type":"object","properties":{"reason":{"type":"string","description":"Why notifications are held back; shown on the silence and on held-back incidents"},"rule_ids":{"type":"array","items":{"type":"string"},"description":"Silence these alert rules (ids from list_alert_rules). Each is its UUID or its REST id (alrt_\u2026)"},"tags":{"type":"array","items":{"type":"string"},"description":"Silence rules carrying any of these tags"},"services":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these services"},"environments":{"type":"array","items":{"type":"string"},"description":"Silence rules and incident groups covered by these environments"},"duration_minutes":{"type":"number","description":"How long the silence lasts from its start, in minutes"},"ends_at":{"type":"string","description":"When the silence ends, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"},"starts_at":{"type":"string","description":"When the silence starts (default: now), UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)"}},"required":["reason"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"silence":{"type":"object","properties":{"id":{"type":"string"},"reason":{"type":"string"},"matchers":{"type":"object","properties":{"ruleIds":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"services":{"type":"array","items":{"type":"string"}},"environments":{"type":"array","items":{"type":"string"}}},"additionalProperties":true},"status":{"type":"string"},"startsAt":{"type":"string"},"endsAt":{"type":"string"},"createdBy":{"type":"string"}},"required":["id","reason","matchers","status","startsAt","endsAt","createdBy"],"additionalProperties":true}},"required":["silence"],"additionalProperties":true},"annotations":{"title":"Create Alert Silence","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}, @@ -99,8 +99,8 @@ export const MCP_TOOL_DESCRIPTORS: ReadonlyArray = [ {"name":"update_scrape_target","title":"Update Scrape Target","description":"Change a Prometheus scrape target: pause or resume it, or change its URL, name, service or interval. Fields left out keep their value, and a stored credential is kept. Org admins and engineers only. Targets managed by an integration (PlanetScale) are changed from that integration in Settings.","inputSchema":{"type":"object","properties":{"target":{"type":"string","description":"The target's name or id (list_integrations provider=prometheus lists them)"},"enabled":{"type":"boolean","description":"false pauses scraping, true resumes it"},"url":{"type":"string","description":"A new metrics URL"},"name":{"type":"string","description":"A new name"},"service":{"type":"string","description":"A new `service.name` for its metrics"},"interval_seconds":{"type":"integer","minimum":5,"maximum":300,"description":"A new scrape interval (5 to 300 seconds)"}},"required":["target"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"target":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"scrapeIntervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"enabled":{"type":"boolean"},"authType":{"type":"string"},"lastScrapeAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastScrapeError":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","url","serviceName","scrapeIntervalSeconds","enabled","authType","lastScrapeAt","lastScrapeError"],"additionalProperties":true}},"required":["action","target"],"additionalProperties":true},"annotations":{"title":"Update Scrape Target","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true}}, {"name":"delete_scrape_target","title":"Delete Scrape Target","description":"Stop scraping a Prometheus target and delete it with its check history; metrics already stored stay. Irreversible; requires confirm=true. To stop it for a while, update_scrape_target enabled=false instead. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"target":{"type":"string","description":"The target's name or id"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["target","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"}},"required":["deleted","id","name"],"additionalProperties":true},"annotations":{"title":"Delete Scrape Target","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, {"name":"list_uptime_monitors","title":"List Uptime Monitors","description":"Every uptime monitor (http, tcp, dns checks run by Maple, and heartbeats your jobs ping) with its status, last check (status code, duration, error, trace id) and the share of its recent checks that passed. Down monitors come first. A failed http/tcp/dns check is a span in the org's traces: inspect_trace reads it.","inputSchema":{"type":"object","properties":{"status":{"type":"string","enum":["pending","up","down","paused"],"description":"Only monitors in this status"},"service":{"type":"string","description":"Only monitors linked to this service (exact `service.name`)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"monitors":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"counts":{"type":"object","properties":{"up":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"down":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"pending":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"paused":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["up","down","pending","paused"],"additionalProperties":true},"statusFilter":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"serviceFilter":{"type":"string"}},"required":["monitors","total","counts"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"List Uptime Monitors","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, - {"name":"create_uptime_monitor","title":"Create Uptime Monitor","description":"Start monitoring an endpoint. kind=http requests `url` and expects `expected_status` (default 2xx), optionally under `max_response_ms` and with `body_contains`; kind=tcp connects to `host`:`port`; kind=dns resolves `host` (`record_type`, optional `expected` answer); kind=heartbeat waits for your job to ping a URL every `heartbeat_interval_seconds` or on a `cron` schedule, plus `grace_seconds`. A monitor that goes down opens an alert incident and notifies `destination_ids`. Org admins and engineers only. Returns the monitor and, for a heartbeat, its ping URL and a curl line for the job.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the monitor, unique among the org's monitors and alert rules"},"kind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"description":"What the monitor checks"},"url":{"type":"string","description":"http: the public http(s) URL to request"},"method":{"type":"string","enum":["GET","HEAD","POST"],"description":"http: request method (default GET)"},"expected_status":{"type":"string","description":"http: expected status, a class like `2xx` (the default) or an exact code like `204`"},"max_response_ms":{"type":"number","description":"http: fail the check when the response takes this many milliseconds or more"},"body_contains":{"type":"string","description":"http: fail the check unless the response body contains this text"},"host":{"type":"string","description":"tcp: hostname or IP to connect to. dns: the name to resolve"},"port":{"type":"number","description":"tcp: port to connect to (1 to 65535)"},"record_type":{"type":"string","enum":["A","AAAA","CNAME","MX","TXT","NS"],"description":"dns: record type to query (default A)"},"expected":{"type":"string","description":"dns: up when any answer equals this; omit to only require that it resolves"},"heartbeat_interval_seconds":{"type":"number","description":"heartbeat: expected seconds between pings (at least 60)"},"cron":{"type":"string","description":"heartbeat: cron expression of the expected pings, instead of an interval"},"timezone":{"type":"string","description":"heartbeat: IANA timezone the cron expression runs in (default UTC)"},"grace_seconds":{"type":"number","description":"heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default 300)"},"interval_seconds":{"type":"number","description":"http/tcp/dns: seconds between checks, 30 to 3600 (default 60)"},"failures_to_down":{"type":"number","description":"http/tcp/dns: failed checks in a row before the monitor is down, 1 to 10 (default 2)"},"service":{"type":"string","description":"The `service.name` the monitor belongs to: links its failures to that service"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations notified when the monitor goes down (list_alert_destinations lists them). Each is its UUID or its REST id (dest_\u2026)"}},"required":["name","kind"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"monitor":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true},"pingCommand":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["action","monitor","pingCommand"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"Create Uptime Monitor","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true}}, - {"name":"update_uptime_monitor","title":"Update Uptime Monitor","description":"Change an uptime monitor: pause it (enabled=false) or resume it (enabled=true, which restarts it from pending), or change its name, target, assertions, schedule, interval, service or destinations. Fields left out keep their value; a monitor cannot change kind. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"monitor":{"type":"string","description":"The monitor's name or id (list_uptime_monitors lists them)"},"enabled":{"type":"boolean","description":"false pauses the monitor, true resumes it"},"name":{"type":"string","description":"A new name"},"url":{"type":"string","description":"http: the public http(s) URL to request"},"method":{"type":"string","enum":["GET","HEAD","POST"],"description":"http: request method (default GET)"},"expected_status":{"type":"string","description":"http: expected status, a class like `2xx` (the default) or an exact code like `204`"},"max_response_ms":{"type":"number","description":"http: fail the check when the response takes this many milliseconds or more"},"body_contains":{"type":"string","description":"http: fail the check unless the response body contains this text"},"host":{"type":"string","description":"tcp: hostname or IP to connect to. dns: the name to resolve"},"port":{"type":"number","description":"tcp: port to connect to (1 to 65535)"},"record_type":{"type":"string","enum":["A","AAAA","CNAME","MX","TXT","NS"],"description":"dns: record type to query (default A)"},"expected":{"type":"string","description":"dns: up when any answer equals this; omit to only require that it resolves"},"heartbeat_interval_seconds":{"type":"number","description":"heartbeat: expected seconds between pings (at least 60)"},"cron":{"type":"string","description":"heartbeat: cron expression of the expected pings, instead of an interval"},"timezone":{"type":"string","description":"heartbeat: IANA timezone the cron expression runs in (default UTC)"},"grace_seconds":{"type":"number","description":"heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default 300)"},"interval_seconds":{"type":"number","description":"http/tcp/dns: seconds between checks, 30 to 3600 (default 60)"},"failures_to_down":{"type":"number","description":"http/tcp/dns: failed checks in a row before the monitor is down, 1 to 10 (default 2)"},"service":{"type":"string","description":"The `service.name` the monitor belongs to: links its failures to that service"},"clear_service":{"type":"boolean","description":"true unlinks the monitor from its service; not with `service`"},"clear_max_response_ms":{"type":"boolean","description":"http: true removes the response time check; not with `max_response_ms`"},"clear_body_contains":{"type":"boolean","description":"http: true removes the body contains check; not with `body_contains`"},"clear_expected":{"type":"boolean","description":"dns: true removes the expected answer; not with `expected`"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Replaces the alert destinations notified when the monitor goes down; [] notifies none. Each is its UUID or its REST id (dest_\u2026)"}},"required":["monitor"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"monitor":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true},"pingCommand":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["action","monitor","pingCommand"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"Update Uptime Monitor","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true}}, + {"name":"create_uptime_monitor","title":"Create Uptime Monitor","description":"Start monitoring an endpoint. kind=http requests `url` and expects `expected_status` (default 2xx), optionally under `max_response_ms` and with `body_contains`; kind=tcp connects to `host`:`port`; kind=dns resolves `host` (`record_type`, optional `expected` answer); kind=heartbeat waits for your job to ping a URL every `heartbeat_interval_seconds` or on a `cron` schedule, plus `grace_seconds`. A monitor that goes down opens an alert incident and notifies `destination_ids`. Org admins and engineers only. Returns the monitor and, for a heartbeat, its ping URL and a curl line for the job.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"A label for the monitor, unique among the org's monitors and alert rules"},"kind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"description":"What the monitor checks"},"url":{"type":"string","description":"http: the public http(s) URL to request"},"method":{"type":"string","enum":["GET","HEAD","POST"],"description":"http: request method (default GET)"},"expected_status":{"type":"string","description":"http: expected status, a class like `2xx` (the default) or an exact code like `204`"},"max_response_ms":{"type":"number","description":"http: fail the check when the response takes this many milliseconds or more"},"body_contains":{"type":"string","description":"http: fail the check unless the response body contains this text"},"host":{"type":"string","description":"tcp: hostname or IP to connect to. dns: the name to resolve"},"port":{"type":"number","description":"tcp: port to connect to (1 to 65535)"},"record_type":{"type":"string","enum":["A","AAAA","CNAME","MX","TXT","NS"],"description":"dns: record type to query (default A)"},"expected":{"type":"string","description":"dns: up when any answer equals this; omit to only require that it resolves"},"heartbeat_interval_seconds":{"type":"number","description":"heartbeat: expected seconds between pings (at least 60)"},"cron":{"type":"string","description":"heartbeat: cron expression of the expected pings, instead of an interval"},"time_zone":{"type":"string","description":"heartbeat: IANA time zone the cron expression runs in (default UTC)"},"grace_seconds":{"type":"number","description":"heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default 300)"},"interval_seconds":{"type":"number","description":"http/tcp/dns: seconds between checks, 30 to 3600 (default 60)"},"failures_to_down":{"type":"number","description":"http/tcp/dns: failed checks in a row before the monitor is down, 1 to 10 (default 2)"},"service":{"type":"string","description":"The `service.name` the monitor belongs to: links its failures to that service"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Alert destinations notified when the monitor goes down (list_alert_destinations lists them). Each is its UUID or its REST id (dest_\u2026)"}},"required":["name","kind"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"monitor":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true},"pingCommand":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["action","monitor","pingCommand"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"Create Uptime Monitor","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":true}}, + {"name":"update_uptime_monitor","title":"Update Uptime Monitor","description":"Change an uptime monitor: pause it (enabled=false) or resume it (enabled=true, which restarts it from pending), or change its name, target, assertions, schedule, interval, service or destinations. Fields left out keep their value; a monitor cannot change kind. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"monitor":{"type":"string","description":"The monitor's name or id (list_uptime_monitors lists them)"},"enabled":{"type":"boolean","description":"false pauses the monitor, true resumes it"},"name":{"type":"string","description":"A new name"},"url":{"type":"string","description":"http: the public http(s) URL to request"},"method":{"type":"string","enum":["GET","HEAD","POST"],"description":"http: request method (default GET)"},"expected_status":{"type":"string","description":"http: expected status, a class like `2xx` (the default) or an exact code like `204`"},"max_response_ms":{"type":"number","description":"http: fail the check when the response takes this many milliseconds or more"},"body_contains":{"type":"string","description":"http: fail the check unless the response body contains this text"},"host":{"type":"string","description":"tcp: hostname or IP to connect to. dns: the name to resolve"},"port":{"type":"number","description":"tcp: port to connect to (1 to 65535)"},"record_type":{"type":"string","enum":["A","AAAA","CNAME","MX","TXT","NS"],"description":"dns: record type to query (default A)"},"expected":{"type":"string","description":"dns: up when any answer equals this; omit to only require that it resolves"},"heartbeat_interval_seconds":{"type":"number","description":"heartbeat: expected seconds between pings (at least 60)"},"cron":{"type":"string","description":"heartbeat: cron expression of the expected pings, instead of an interval"},"time_zone":{"type":"string","description":"heartbeat: IANA time zone the cron expression runs in (default UTC)"},"grace_seconds":{"type":"number","description":"heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default 300)"},"interval_seconds":{"type":"number","description":"http/tcp/dns: seconds between checks, 30 to 3600 (default 60)"},"failures_to_down":{"type":"number","description":"http/tcp/dns: failed checks in a row before the monitor is down, 1 to 10 (default 2)"},"service":{"type":"string","description":"The `service.name` the monitor belongs to: links its failures to that service"},"clear_service":{"type":"boolean","description":"true unlinks the monitor from its service; not with `service`"},"clear_max_response_ms":{"type":"boolean","description":"http: true removes the response time check; not with `max_response_ms`"},"clear_body_contains":{"type":"boolean","description":"http: true removes the body contains check; not with `body_contains`"},"clear_expected":{"type":"boolean","description":"dns: true removes the expected answer; not with `expected`"},"destination_ids":{"type":"array","items":{"type":"string"},"description":"Replaces the alert destinations notified when the monitor goes down; [] notifies none. Each is its UUID or its REST id (dest_\u2026)"}},"required":["monitor"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["created","updated"]},"monitor":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"kind":{"$ref":"#/$defs/@maple~1UptimeMonitorKind"},"target":{"type":"string"},"status":{"$ref":"#/$defs/@maple~1UptimeMonitorStatus"},"enabled":{"type":"boolean"},"serviceName":{"anyOf":[{"type":"string"},{"type":"null"}]},"intervalSeconds":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"failuresToDown":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"statusChangedAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"lastCheck":{"anyOf":[{"type":"object","properties":{"at":{"type":"string"},"ok":{"type":"boolean"},"statusCode":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"durationMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"traceId":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["at","ok","statusCode","durationMs","error","traceId"],"additionalProperties":true},{"type":"null"}]},"uptimeRatio":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"recentChecks":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"lastPingAt":{"anyOf":[{"type":"string"},{"type":"null"}]},"heartbeatUrl":{"anyOf":[{"type":"string"},{"type":"null"}]},"destinationIds":{"type":"array","items":{"type":"string"}}},"required":["id","name","kind","target","status","enabled","serviceName","intervalSeconds","failuresToDown","statusChangedAt","lastCheck","uptimeRatio","recentChecks","lastPingAt","heartbeatUrl","destinationIds"],"additionalProperties":true},"pingCommand":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["action","monitor","pingCommand"],"additionalProperties":true,"$defs":{"@maple/UptimeMonitorKind":{"type":"string","enum":["http","tcp","dns","heartbeat"],"title":"Uptime Monitor Kind"},"@maple/UptimeMonitorStatus":{"type":"string","enum":["pending","up","down","paused"],"title":"Uptime Monitor Status"}}},"annotations":{"title":"Update Uptime Monitor","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":true}}, {"name":"delete_uptime_monitor","title":"Delete Uptime Monitor","description":"Delete an uptime monitor and its alert rule. Check spans already in the traces stay. Irreversible; requires confirm=true. To stop it for a while, update_uptime_monitor enabled=false instead. Org admins and engineers only.","inputSchema":{"type":"object","properties":{"monitor":{"type":"string","description":"The monitor's name or id"},"confirm":{"type":"boolean","description":"Must be true. There is no undo."}},"required":["monitor","confirm"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"deleted":{"type":"boolean"},"id":{"type":"string"},"name":{"type":"string"}},"required":["deleted","id","name"],"additionalProperties":true},"annotations":{"title":"Delete Uptime Monitor","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}}, {"name":"list_slos","title":"List SLOs","description":"Every SLO with its current SLI, error budget left and the worst burn rate over the 5m, 30m, 1h and 6h windows. SLOs whose burn-rate alert tier is breaching come first (critical, then warning), then the least budget left. inspect_slo shows one SLO's windows and alerting.","inputSchema":{"type":"object","properties":{"service":{"type":"string","description":"Only SLOs on this service (exact `service.name`)"}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slos":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"worstBurnWindow":{"anyOf":[{"type":"string"},{"type":"null"}]},"burning":{"anyOf":[{"type":"string","enum":["critical","warning"]},{"type":"null"}]},"holding":{"type":"boolean"},"statusError":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt","sli","budgetRemaining","worstBurnRate","worstBurnWindow","burning","holding","statusError"],"additionalProperties":true}},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"serviceFilter":{"type":"string"}},"required":["slos","total"],"additionalProperties":true},"annotations":{"title":"List SLOs","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, {"name":"inspect_slo","title":"Inspect SLO","description":"One SLO in full: its config (service, environments, SLI, target, window), where it stands now (total and good events, SLI, error budget left, the burn rate of each 5m, 30m, 1h and 6h window and which alert tier breaches) and its burn-rate alerting (on or off, destinations per tier, alert rule id).","inputSchema":{"type":"object","properties":{"slo":{"type":"string","description":"The SLO's name or id (list_slos lists them)"}},"required":["slo"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"slo":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"serviceName":{"type":"string"},"environments":{"type":"array","items":{"type":"string"}},"sliKind":{"type":"string","enum":["availability","latency"]},"latencyThresholdMs":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"targetPercent":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"windowDays":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"alerting":{"type":"object","properties":{"enabled":{"type":"boolean"},"alertRuleId":{"anyOf":[{"type":"string"},{"type":"null"}]},"criticalDestinationIds":{"type":"array","items":{"type":"string"}},"warningDestinationIds":{"type":"array","items":{"type":"string"}}},"required":["enabled","alertRuleId","criticalDestinationIds","warningDestinationIds"],"additionalProperties":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","description","serviceName","environments","sliKind","latencyThresholdMs","targetPercent","windowDays","alerting","createdAt","updatedAt"],"additionalProperties":true},"status":{"type":"object","properties":{"asOf":{"type":"string"},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"sli":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]},{"type":"null"}]},"budgetRemaining":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnWindows":{"type":"array","items":{"type":"object","properties":{"window":{"type":"string"},"windowMinutes":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"total":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"good":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"burnRate":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]}},"required":["window","windowMinutes","total","good","burnRate"],"additionalProperties":true}},"tiers":{"type":"array","items":{"type":"object","properties":{"severity":{"type":"string","enum":["critical","warning"]},"factor":{"anyOf":[{"type":"number"},{"type":"string","enum":["Infinity","-Infinity","NaN"]}]},"longWindow":{"type":"string"},"shortWindow":{"type":"string"},"breached":{"type":"boolean"},"state":{"type":"string","enum":["breach","hold","clear"]}},"required":["severity","factor","longWindow","shortWindow","breached","state"],"additionalProperties":true}}},"required":["asOf","total","good","sli","budgetRemaining","burnWindows","tiers"],"additionalProperties":true}},"required":["slo","status"],"additionalProperties":true},"annotations":{"title":"Inspect SLO","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}, diff --git a/apps/ai/src/mcp/tools/__tests__/maintenance-window-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/maintenance-window-tools.test.ts index c524f039ee..4a9e5b4708 100644 --- a/apps/ai/src/mcp/tools/__tests__/maintenance-window-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/maintenance-window-tools.test.ts @@ -63,7 +63,7 @@ const weekly = { schedule_type: "weekly", time_zone: "Europe/Berlin", days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], - start_time: "02:00", + start_time_of_day: "02:00", duration_minutes: "60", } @@ -107,6 +107,50 @@ describe("maintenance window tools", () => { }).pipe(Effect.provide(layerFor(testDb))) }) + /** `since` is a lookback on tools with a window; here it must not land in the HH:MM field. */ + it.effect("create_maintenance_window keeps its HH:MM start when a lookback is sent with it", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const created = yield* call("create_maintenance_window", { + ...weekly, + services: "checkout", + since: "2h", + }) + assert.strictEqual(created._tag, "Success") + if (created._tag !== "Success") return + const { window } = Schema.decodeUnknownSync(MaintenanceWindowMutationOutput)( + created.success.structuredContent, + ) + assert.strictEqual( + window.schedule.type === "weekly" ? window.schedule.startTime : undefined, + "02:00", + ) + assert.include(text(created.success), "`since` is not a parameter") + }).pipe(Effect.provide(layerFor(testDb))) + }) + + it.effect("create_maintenance_window still reads the retired start_time as the HH:MM start", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const { start_time_of_day, ...rest } = weekly + const created = yield* call("create_maintenance_window", { + ...rest, + services: "checkout", + start_time: start_time_of_day, + since: "2h", + }) + assert.strictEqual(created._tag, "Success") + if (created._tag !== "Success") return + const { window } = Schema.decodeUnknownSync(MaintenanceWindowMutationOutput)( + created.success.structuredContent, + ) + assert.strictEqual( + window.schedule.type === "weekly" ? window.schedule.startTime : undefined, + "02:00", + ) + }).pipe(Effect.provide(layerFor(testDb))) + }) + it.effect("create_maintenance_window says a tag-only window leaves baselines alone", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { diff --git a/apps/ai/src/mcp/tools/__tests__/uptime-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/uptime-tools.test.ts index ed689f87c3..419d11735f 100644 --- a/apps/ai/src/mcp/tools/__tests__/uptime-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/uptime-tools.test.ts @@ -78,15 +78,15 @@ describe("create_uptime_monitor", () => { expect(markdown(result)).toContain("structured output") }) - it("refuses a timezone with an interval heartbeat instead of dropping it", async () => { + it("refuses a time zone with an interval heartbeat instead of dropping it", async () => { const created = await runToolDirect(admin, "create_uptime_monitor", { name: "hb-zone", kind: "heartbeat", heartbeat_interval_seconds: 3600, - timezone: "Europe/Berlin", + time_zone: "Europe/Berlin", }) expect(created.isError).toBe(true) - expect(markdown(created)).toContain("timezone applies to a cron schedule") + expect(markdown(created)).toContain("time_zone applies to a cron schedule") await runToolDirect(admin, "create_uptime_monitor", { name: "hb-zone-update", @@ -96,10 +96,11 @@ describe("create_uptime_monitor", () => { const updated = await runToolDirect(admin, "update_uptime_monitor", { monitor: "hb-zone-update", heartbeat_interval_seconds: 7200, + // The retired spelling is still read, as the same parameter. timezone: "Europe/Berlin", }) expect(updated.isError).toBe(true) - expect(markdown(updated)).toContain("timezone applies to a cron schedule") + expect(markdown(updated)).toContain("time_zone applies to a cron schedule") }) it("refuses probe-only settings for a heartbeat, naming the field", async () => { diff --git a/apps/ai/src/mcp/tools/create-maintenance-window.ts b/apps/ai/src/mcp/tools/create-maintenance-window.ts index 3704f033a6..f3d6d9722f 100644 --- a/apps/ai/src/mcp/tools/create-maintenance-window.ts +++ b/apps/ai/src/mcp/tools/create-maintenance-window.ts @@ -31,7 +31,7 @@ interface ScheduleParams { readonly ends_at?: string | undefined readonly time_zone?: string | undefined readonly days?: ReadonlyArray | undefined - readonly start_time?: string | undefined + readonly start_time_of_day?: string | undefined readonly duration_minutes?: number | undefined } @@ -40,15 +40,15 @@ const scheduleFrom = Effect.fnUntraced(function* (params: ScheduleParams) { if (params.schedule_type === "weekly") { if (params.days === undefined || params.days.length === 0) return yield* invalid('A weekly window needs days, e.g. ["sat", "sun"].', "days") - if (params.start_time === undefined) - return yield* invalid("A weekly window needs start_time as HH:MM.", "start_time") + if (params.start_time_of_day === undefined) + return yield* invalid("A weekly window needs start_time_of_day as HH:MM.", "start_time_of_day") if (params.duration_minutes === undefined) return yield* invalid("A weekly window needs duration_minutes.", "duration_minutes") return { _tag: "weekly", timeZone: params.time_zone ?? "UTC", days: params.days, - startTime: params.start_time, + startTime: params.start_time_of_day, durationMinutes: params.duration_minutes, } } @@ -83,7 +83,7 @@ export function registerCreateMaintenanceWindowTool(server: McpToolRegistrar) { description: "Schedule a maintenance window: alert notifications for matching rules are held back during each occurrence (incidents still open and resolve). " + "schedule_type=once takes starts_at (default now) and ends_at or duration_minutes (max 30 days); " + - "schedule_type=weekly takes days, start_time (HH:MM, local to time_zone) and duration_minutes (5 to 10080). " + + "schedule_type=weekly takes days, start_time_of_day (HH:MM, local to time_zone) and duration_minutes (5 to 10080). " + "Matchers AND together, values inside one OR; pass at least one. With services or environments, the window's hours are also left out of anomaly baselines unless exclude_from_baselines=false.", parameters: Schema.Struct({ name: P.text("Short name, shown on the window and on the silences it writes"), @@ -92,10 +92,11 @@ export function registerCreateMaintenanceWindowTool(server: McpToolRegistrar) { starts_at: P.optionalTimestamp("When a one-off window opens (default: now)"), ends_at: P.optionalTimestamp("When a one-off window closes"), time_zone: P.optionalText( - "IANA time zone a weekly start_time is read in, e.g. Europe/Berlin (default UTC)", + "IANA time zone a weekly start_time_of_day is read in, e.g. Europe/Berlin (default UTC)", ), days: P.optionalOneOfList(MAINTENANCE_WEEKDAYS, "Weekdays a weekly window opens on"), - start_time: P.optionalText("Local start of a weekly window, HH:MM (24-hour)"), + // Not `start_time`: that name is a window bound, which a lookback (`since: "2h"`) is written into. + start_time_of_day: P.optionalText("Local start of a weekly window, HH:MM (24-hour)"), duration_minutes: P.optionalNumber("How long each occurrence lasts, in minutes"), rule_ids: P.optionalResourceIdList( "alertRule", @@ -108,7 +109,7 @@ export function registerCreateMaintenanceWindowTool(server: McpToolRegistrar) { "Leave the window's hours out of anomaly baselines (default true; needs services or environments)", ), }), - aliases: { service_names: "services" }, + aliases: { service_names: "services", start_time: "start_time_of_day" }, output: MaintenanceWindowMutationOutput, hints: { readOnly: false, destructive: false, idempotent: false }, phrases: ["Scheduling a maintenance window", "Creating a maintenance window"], diff --git a/apps/ai/src/mcp/tools/llm-tools.test.ts b/apps/ai/src/mcp/tools/llm-tools.test.ts index dcc9f4967b..5aa7b8625e 100644 --- a/apps/ai/src/mcp/tools/llm-tools.test.ts +++ b/apps/ai/src/mcp/tools/llm-tools.test.ts @@ -16,7 +16,7 @@ import { makeRecordingTracer } from "@maple/backend/testing/recording-tracer" import { AgentSpawner } from "@yielded/agent/agent-runtime" import { AgentId, RunId, ThreadId } from "@yielded/agent/identifiers" import { agentForSession, agentPolicyFor } from "../../chat/agents" -import { APPROVAL_NOTE, buildMapleToolkit, splitToolResult, type ToolUiPayload } from "./llm-tools" +import { APPROVAL_NOTE, buildMapleToolkit, type ToolUiPayload } from "./llm-tools" const TENANT: TenantContext = { orgId: Schema.decodeSync(OrgId)("org_test"), @@ -142,15 +142,6 @@ describe("buildMapleToolkit", () => { ]) }) - it("strips a legacy UI block from the model's text and still recovers it for the UI", () => { - const legacy = JSON.stringify({ __maple_ui: true, tool: "list_services", data: { services: [] } }) - const split = splitToolResult("list_services", { - content: [{ text: "## Services" }, { text: legacy }], - }) - assert.equal(split.text, "## Services") - assert.deepEqual(split.ui, { __maple_ui: true, tool: "list_services", data: { services: [] } }) - }) - it("fails a tool that reported an error with its message", async () => { const executor: McpToolExecutorApi = { ...noRepositoryWarmup, diff --git a/apps/ai/src/mcp/tools/llm-tools.ts b/apps/ai/src/mcp/tools/llm-tools.ts index d2fd05be1e..3d4628e379 100644 --- a/apps/ai/src/mcp/tools/llm-tools.ts +++ b/apps/ai/src/mcp/tools/llm-tools.ts @@ -40,40 +40,26 @@ export interface ToolUiPayload { readonly data: Schema.Json } -const LegacyUiBlock = Schema.fromJsonString( - Schema.Struct({ [UI_MARKER]: Schema.Literal(true), tool: Schema.String, data: Schema.Json }), -) -const decodeLegacyUiBlock = Schema.decodeUnknownOption(LegacyUiBlock) - -const isLegacyUiBlock = (text: string): boolean => Str.startsWith(`{"${UI_MARKER}":true`)(text) - /** * The model's text and the UI's payload, split. The payload is never shown to a model: it doubled * every result it rode along on, and half of all tool results carried one. */ -export const splitToolResult = ( +const splitToolResult = ( name: string, result: { readonly content: ReadonlyArray<{ readonly text: string }> readonly structuredContent?: Schema.Json }, -): { readonly text: string; readonly ui: ToolUiPayload | undefined } => { - const legacy = result.content.find((block) => isLegacyUiBlock(block.text)) - const ui: ToolUiPayload | undefined = - result.structuredContent !== undefined - ? { [UI_MARKER]: true, tool: name, data: result.structuredContent } - : legacy === undefined - ? undefined - : Option.getOrUndefined(decodeLegacyUiBlock(legacy.text)) - const text = Arr.join( - Arr.map( - Arr.filter(result.content, (block) => !isLegacyUiBlock(block.text)), - (block) => block.text, - ), +): { readonly text: string; readonly ui: ToolUiPayload | undefined } => ({ + text: Arr.join( + Arr.map(result.content, (block) => block.text), "\n\n", - ) - return { text, ui } -} + ), + ui: + result.structuredContent === undefined + ? undefined + : { [UI_MARKER]: true, tool: name, data: result.structuredContent }, +}) /** * Capped, because "one line" is a convention the error's author never agreed to: a ClickHouse syntax diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index b6d0d6dbee..2ba13fe84c 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -1,7 +1,7 @@ /** * Base names of the mutating MCP tools that the AI chat gates behind approval. * - * The in-process chat agent (`apps/api/src/chat/`) interrupts the turn on these + * The in-process chat agent (`apps/ai/src/chat/`) interrupts the turn on these * tools instead of executing them; an approval runs the real change through * `ChatSession.settleProposal`, whose applier only accepts tools in this set. */ diff --git a/apps/ai/src/mcp/tools/registry.contract.test.ts b/apps/ai/src/mcp/tools/registry.contract.test.ts index 8c6dfbdf12..54f6e4d750 100644 --- a/apps/ai/src/mcp/tools/registry.contract.test.ts +++ b/apps/ai/src/mcp/tools/registry.contract.test.ts @@ -9,7 +9,7 @@ import { McpToolOutputs } from "@maple/domain/mcp-outputs" import { Array as Arr, Record as Rec, String as Str } from "effect" /** Parameter names that were retired for one spelling per concept. They live on only as aliases. */ -const RETIRED_PARAMETERS = new Set(["service_name", "service_names", "since", "until"]) +const RETIRED_PARAMETERS = new Set(["service_name", "service_names", "since", "until", "timezone"]) const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Arr.isArray(value) diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index c1b3ad3f53..f2f984c10e 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -229,7 +229,7 @@ const isEmptyStructSchema = (base: Record): boolean => { * `Schema.optional(X)` — which CLAUDE.md mandates for MCP tool params — has type * `X | undefined`, but `toJsonSchemaDocument` renders that absence as a JSON * `null` branch. The published schema therefore told every MCP client that - * `{"service": null}` was valid on every optional parameter of all 57 tools, + * `{"service": null}` was valid on every optional parameter of every tool, * while the decoder rejects it with `Expected string | undefined`. An agent that * read the schema literally got "Invalid parameters" for doing what it was told. * @@ -355,7 +355,7 @@ export const toInputSchema = (schema: Schema.Top): Record => { // `onExcessProperty: "error"` keeps `additionalProperties: false` on every // published tool. rc.116 made the emitted value follow this option and // defaults it to the decoder's behaviour, which would have loosened the - // schema all 57 public MCP tools advertise. + // schema every public MCP tool advertises. const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" }) const rawBase = Rec.keys(document.definitions).length > 0 diff --git a/apps/ai/src/mcp/tools/uptime-monitors.ts b/apps/ai/src/mcp/tools/uptime-monitors.ts index 9764e2850e..410125ce23 100644 --- a/apps/ai/src/mcp/tools/uptime-monitors.ts +++ b/apps/ai/src/mcp/tools/uptime-monitors.ts @@ -219,7 +219,7 @@ const configParameters = { expected: P.optionalText("dns: up when any answer equals this; omit to only require that it resolves"), heartbeat_interval_seconds: P.optionalNumber("heartbeat: expected seconds between pings (at least 60)"), cron: P.optionalText("heartbeat: cron expression of the expected pings, instead of an interval"), - timezone: P.optionalText("heartbeat: IANA timezone the cron expression runs in (default UTC)"), + time_zone: P.optionalText("heartbeat: IANA time zone the cron expression runs in (default UTC)"), grace_seconds: P.optionalNumber( `heartbeat: how late a ping may be before the monitor goes down (0 to 86400, default ${DEFAULT_GRACE_SECONDS})`, ), @@ -230,7 +230,7 @@ const configParameters = { service: P.service("The `service.name` the monitor belongs to: links its failures to that service"), } as const -const ALIASES = { ...P.SERVICE_ALIASES, hostname: "host" } as const +const ALIASES = { ...P.SERVICE_ALIASES, hostname: "host", timezone: "time_zone" } as const const configFields = (params: UptimeConfigFields): UptimeConfigFields => ({ url: params.url, @@ -244,7 +244,7 @@ const configFields = (params: UptimeConfigFields): UptimeConfigFields => ({ expected: params.expected, heartbeat_interval_seconds: params.heartbeat_interval_seconds, cron: params.cron, - timezone: params.timezone, + time_zone: params.time_zone, grace_seconds: params.grace_seconds, }) diff --git a/apps/ai/src/platform/Llm.ts b/apps/ai/src/platform/Llm.ts index c27a361104..67560cb5d8 100644 --- a/apps/ai/src/platform/Llm.ts +++ b/apps/ai/src/platform/Llm.ts @@ -553,8 +553,8 @@ export const resolveTriageModel = ( ? EU_DEFAULT_OPENROUTER_MODEL : DEFAULT_OPENROUTER_MODEL), "MAPLE_TRIAGE_REASONING_EFFORT", - // No default. This resolver serves chat, AI triage *and* the validator, so a number - // picked here would retune three stages with different shapes at once. + // No default. This resolver serves chat and AI triage, so a number picked here would + // retune two stages with different shapes at once. undefined, tags, true, diff --git a/apps/ai/src/platform/genai-spans.test.ts b/apps/ai/src/platform/genai-spans.test.ts index 5c4ca79958..bf0f54d211 100644 --- a/apps/ai/src/platform/genai-spans.test.ts +++ b/apps/ai/src/platform/genai-spans.test.ts @@ -3,11 +3,9 @@ * and tool results run to 50k, so these paths run in production far more often than the happy one. */ import { assert, describe, it } from "@effect/vitest" -import { Array as Arr, Effect, Exit, Option, Schema, String as Str } from "effect" -import { Tool } from "effect/ai" +import { Array as Arr, Effect, Exit, Option, String as Str } from "effect" import { makeRecordingTracer } from "@maple/backend/testing/recording-tracer" import { - invokeAgentAttributes, messagesJson, toolCallJson, withReturnedToolFailuresOk, @@ -128,30 +126,6 @@ describe("toolCallJson", () => { }) }) -describe("invokeAgentAttributes", () => { - it("drops schemas and shortens descriptions when the definitions outweigh their budget", () => { - const attributes = invokeAgentAttributes({ - agentName: "lens", - agentDescription: "", - conversationId: "pass-1", - providerName: "openrouter", - model: "test-model", - tools: Array.from({ length: 40 }, (_, index) => - Tool.make(`tool_${index}`, { - description: Str.repeat(1_000)("d"), - parameters: Schema.Struct({ query: Schema.String }), - }), - ), - }) - const definitions = JSON.parse(String(attributes["gen_ai.tool.definitions"])) - - assert.strictEqual(definitions.length, 40) - assert.notProperty(definitions[0], "parameters") - assert.strictEqual(definitions[0].description, `${Str.repeat(128)("d")}${TRUNCATION_MARKER}`) - assert.notProperty(attributes, "gen_ai.agent.description") - }) -}) - describe("withToolCallContent", () => { it.effect("leaves the handler and its span alone when there is no tool span to describe", () => Effect.gen(function* () { diff --git a/apps/ai/src/platform/genai-spans.ts b/apps/ai/src/platform/genai-spans.ts index 3c09e51a92..af255f7178 100644 --- a/apps/ai/src/platform/genai-spans.ts +++ b/apps/ai/src/platform/genai-spans.ts @@ -36,7 +36,7 @@ import * as AiError from "effect/ai/AiError" import type * as LanguageModel from "effect/ai/LanguageModel" import type * as Prompt from "effect/ai/Prompt" import * as Telemetry from "effect/ai/Telemetry" -import * as Tool from "effect/ai/Tool" +import type * as Tool from "effect/ai/Tool" import type * as Toolkit from "effect/ai/Toolkit" import { coalesceDeltas } from "./coalesce-deltas" @@ -51,8 +51,6 @@ const INPUT_MESSAGES_BUDGET = 20_000 const OUTPUT_MESSAGES_BUDGET = 8_000 const TOOL_JSON_BUDGET = 8_000 const SYSTEM_INSTRUCTIONS_BUDGET = 8_000 -const TOOL_DEFINITIONS_BUDGET = 16_000 -const AGENT_DESCRIPTION_BUDGET = 1_024 type SerializedPart = | { readonly type: "text"; readonly content: string } @@ -547,67 +545,6 @@ export const toolCallJson = (value: unknown): string => { return withinToolBudget(json, (prefix) => encodeJson({ truncated: true, prefix })) } -/** - * `gen_ai.tool.definitions`: `[{type, name, description, parameters}]`. Schemas are the bulk, so over - * budget they are the first thing dropped — names and the opening of each description are what the - * session view needs, and 128 characters keeps the chat agent's ~60 MCP tools inside the budget. - */ -const toolDefinitionsJson = (tools: ReadonlyArray): string | undefined => { - if (tools.length === 0) return undefined - const described = Arr.map(tools, (tool) => ({ - type: "function", - // `Tool.Any` types the name parameter as `any`. - name: String(tool.name), - description: Tool.getDescription(tool) ?? "", - })) - // A schema that cannot be expressed as JSON Schema throws here; it degrades to the compact form. - const full = Option.getOrUndefined( - Option.liftThrowable(() => - encodeJson( - Arr.map(tools, (tool, index) => ({ - ...described[index], - parameters: Tool.getJsonSchema(tool), - })), - ), - )(), - ) - if (full !== undefined && full.length <= TOOL_DEFINITIONS_BUDGET) return full - return encodeJson( - Arr.map(described, (tool) => ({ ...tool, description: truncated(tool.description, 128) })), - ) -} - -/** - * The `invoke_agent` half of a headless agent pass: which agent ran, on what, with which tools. - * - * Tool definitions go here rather than on every model call: they are the same for the whole pass, - * and at up to 16k characters each they would otherwise be repeated on every step. - */ -export const invokeAgentAttributes = (options: { - readonly agentName: string - readonly agentDescription: string - /** The engine's thread id, which its `execute_tool` spans carry as `gen_ai.conversation.id`. */ - readonly conversationId: string - readonly providerName: string - readonly model: string - readonly tools: ReadonlyArray -}): Readonly> => { - const definitions = toolDefinitionsJson(options.tools) - return { - "gen_ai.operation.name": "invoke_agent", - "gen_ai.agent.name": options.agentName, - // Bounded: hypothesis agents put planner-written text there, the one description with no - // length guarantee. - ...(options.agentDescription === "" - ? undefined - : { "gen_ai.agent.description": truncated(options.agentDescription, AGENT_DESCRIPTION_BUDGET) }), - "gen_ai.conversation.id": options.conversationId, - "gen_ai.provider.name": options.providerName, - "gen_ai.request.model": options.model, - ...(definitions === undefined ? undefined : { "gen_ai.tool.definitions": definitions }), - } -} - const EXECUTE_TOOL_SPAN_PREFIX = "execute_tool " /** diff --git a/apps/ai/src/routes/chat-sessions.http.test.ts b/apps/ai/src/routes/chat-sessions.http.test.ts index 812a5f2603..d16439609e 100644 --- a/apps/ai/src/routes/chat-sessions.http.test.ts +++ b/apps/ai/src/routes/chat-sessions.http.test.ts @@ -1,5 +1,5 @@ import { afterEach, assert, describe, it } from "@effect/vitest" -import { OrgId, UserId } from "@maple/domain/http" +import { AuthorizationUnavailableError, OrgId, UserId } from "@maple/domain/http" import { fakeChatSessionsLayer } from "@maple/backend/platform/chat-sessions-fake" import { envPorts } from "@maple/backend/platform/env-ports" import { ConfigProvider, Effect, Layer, Schema } from "effect" @@ -67,7 +67,11 @@ const makeStub = (turns: Array): ChatSessionStub => ({ * every service a handler needs has to have been captured while the router was built. This is * the shape that answered every chat request with "Service not found" until it was. */ -const makeRouterLayer = (testDb: TestDb, turns: Array) => { +const makeRouterLayer = ( + testDb: TestDb, + turns: Array, + auth: Layer.Layer> = AuthService.layer, +) => { const base = Layer.mergeAll(testDb.layer, Env.layer.pipe(Layer.provide(config))) const workerEnv = Layer.merge( envPorts({}), @@ -77,7 +81,7 @@ const makeRouterLayer = (testDb: TestDb, turns: Array) => { Layer.provide( Layer.mergeAll( ApiKeysService.layer.pipe(Layer.provide(base)), - AuthService.layer.pipe(Layer.provide(base)), + auth.pipe(Layer.provide(base)), ErrorActorsService.layer.pipe(Layer.provide(base)), base, workerEnv, @@ -90,8 +94,9 @@ const withHandler = ( testDb: TestDb, turns: Array, body: (handler: (request: Request) => Promise) => Effect.Effect, + auth?: Parameters[2], ) => { - const { handler, dispose } = HttpRouter.toWebHandler(makeRouterLayer(testDb, turns), { + const { handler, dispose } = HttpRouter.toWebHandler(makeRouterLayer(testDb, turns, auth), { disableLogger: true, }) return body((request) => handler(request)).pipe(Effect.ensuring(Effect.promise(dispose))) @@ -284,6 +289,34 @@ describe("ChatSessionsRouter", () => { ) }) + it.effect("answers 503, not a sign-in prompt, while the auth provider is away", () => { + const testDb = createTestDb(trackedDbs) + const turns: Array = [] + const authProviderAway = Layer.effect( + AuthService, + Effect.gen(function* () { + const auth = yield* AuthService + return { + ...auth, + resolveTenant: () => + Effect.fail( + new AuthorizationUnavailableError({ message: "The auth provider is away" }), + ), + } + }), + ).pipe(Layer.provide(AuthService.layer)) + return withHandler( + testDb, + turns, + Effect.fnUntraced(function* (handler) { + const response = yield* send(handler, { authorization: "Bearer aaa.bbb.ccc" }) + assert.strictEqual(response.status, 503) + assert.strictEqual(turns.length, 0) + }), + authProviderAway, + ) + }) + it.effect("hides another org's session as 404", () => { const testDb = createTestDb(trackedDbs) const turns: Array = [] diff --git a/apps/ai/src/routes/chat-sessions.http.ts b/apps/ai/src/routes/chat-sessions.http.ts index 410c735b82..9bb8feab9e 100644 --- a/apps/ai/src/routes/chat-sessions.http.ts +++ b/apps/ai/src/routes/chat-sessions.http.ts @@ -1,5 +1,5 @@ /** - * Maple's own durable chat transport, replacing Flue's `/agents/maple-chat/:id` surface. + * Maple's own durable chat transport. * * GET /api/chat/sessions/:sessionId/history materialized transcript + resume cursor * POST /api/chat/sessions/:sessionId/messages submit a prompt, start the turn @@ -15,8 +15,7 @@ * * **Tenancy.** The org is recovered from the session id (`":"`) and matched against * the caller's resolved tenant. It is never taken from a body or header, so a client cannot address - * another org's conversation by asking nicely — the same deny-by-default rule Flue's - * `instanceIdFromAgentPath` guard enforced, kept at the one place that now matters. + * another org's conversation by asking nicely. * * **Lifetime.** No handler here runs a turn. `beginTurn` starts it inside the Durable Object, which * owns its own lifetime; a turn forked off this request would be cancelled the moment the 202 was @@ -34,7 +33,7 @@ import { } from "@maple/domain/chat-session" import type { ChatSessionCallError, ChatSessionClient } from "@maple/domain/chat-session-stub" import { ChatSessions } from "@maple/backend/platform/bindings" -import { Context, Effect, Layer, Option, Schema, Stream, String as Str } from "effect" +import { Context, Effect, Layer, Option, Result, Schema, Stream, String as Str } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" import * as SecureRandom from "effect/crypto/SecureRandom" import { AuthService } from "@maple/backend/services/auth/AuthService" @@ -120,18 +119,24 @@ const resolveSession = Effect.fn("ChatSessionsRouter.resolveSession")(function* if (!sessionOrgId) return { ok: false, failure: problem("Malformed chat session id", 400) } as const // Unauthenticated and bad-credential requests are 401, not the 500 an uncaught `McpAuth*Error` - // would produce out of a raw router. `Effect.option` rather than `catchTags` because the - // resolver's whole error channel *is* the auth failures. Session-only: API keys and MCP OAuth - // tokens are refused here, since a turn from this route runs as an attended `app` turn. - const tenant: Option.Option = yield* HttpServerRequest.toWeb(request).pipe( + // would produce out of a raw router. Session-only: API keys and MCP OAuth tokens are refused + // here, since a turn from this route runs as an attended `app` turn. + const resolved = yield* HttpServerRequest.toWeb(request).pipe( Effect.flatMap(resolveChatSessionTenantContext), - Effect.option, + Effect.result, ) - if (Option.isNone(tenant)) { - return { ok: false, failure: problem("Authentication required", 401) } as const + if (Result.isFailure(resolved)) { + // The auth provider being away is retryable: a 401 would send the reader to sign in again. + return resolved.failure._tag === "@maple/mcp/errors/McpAuthUnavailableError" + ? ({ + ok: false, + failure: problem("Authentication is temporarily unavailable", 503), + } as const) + : ({ ok: false, failure: problem("Authentication required", 401) } as const) } + const tenant: TenantContext = resolved.success - if (tenant.value.orgId !== sessionOrgId) { + if (tenant.orgId !== sessionOrgId) { // Deliberately 404, not 403: confirming that another org's conversation exists is itself // a leak. return { ok: false, failure: problem("Chat session not found", 404) } as const @@ -144,7 +149,7 @@ const resolveSession = Effect.fn("ChatSessionsRouter.resolveSession")(function* const stub = (yield* ChatSessions).session(sessionId) - const session: ResolvedSession = { sessionId, tenant: tenant.value, stub, url } + const session: ResolvedSession = { sessionId, tenant, stub, url } return { ok: true, session } as const }) diff --git a/apps/ai/src/routes/health.ts b/apps/ai/src/routes/health.ts index f28e99d6ad..29b0ca31c1 100644 --- a/apps/ai/src/routes/health.ts +++ b/apps/ai/src/routes/health.ts @@ -1,13 +1,11 @@ /** * Liveness, answered without touching the layer graph, the database, or a - * binding — a check that builds the graph reports the graph's health, which is - * the thing most likely to be broken when you ask. - * - * A raw router rather than an `HttpApi` endpoint so it stays outside the typed - * surface entirely, matching the api's own `/health`. + * binding: a check that builds the graph reports the graph's health, which is + * the thing most likely to be broken when you ask. `makeFetch` answers it before + * it picks a graph, so no router carries the route. */ import { Config, Effect, Option } from "effect" -import { HttpRouter, HttpServerResponse } from "effect/http" +import { HttpServerResponse } from "effect/http" /** The revision this isolate runs, so a deploy can assert the script now serving is the one it uploaded. */ export const healthResponse = Effect.map( @@ -20,5 +18,3 @@ export const healthResponse = Effect.map( }), }), ) - -export const HealthRouter = HttpRouter.use((router) => router.add("GET", "/health", () => healthResponse)) diff --git a/apps/ai/src/runtime/http-graph.ts b/apps/ai/src/runtime/http-graph.ts index 215b299311..e43e852600 100644 --- a/apps/ai/src/runtime/http-graph.ts +++ b/apps/ai/src/runtime/http-graph.ts @@ -37,7 +37,6 @@ import { HttpSecurityAuditLive } from "../routes/security-audit.http" import { layerDecisionModelFromConfig } from "../platform/Llm" import { WorkersAiGateway } from "../platform/WorkersAiHttpClient" import { ChatSessionsRouter } from "../routes/chat-sessions.http" -import { HealthRouter } from "../routes/health" import { API_CORS_OPTIONS } from "@maple/backend/http/api-cors" import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import { AiCredits } from "@maple/backend/services/billing/AiCredits" @@ -46,7 +45,7 @@ import { SecurityAuditStore } from "@maple/backend/services/security-audit/Secur import { ApiErrorBoundaryLive } from "@maple/backend/http/error-boundary" import { rawRoutes } from "./raw-routes" -const RawRoutes = rawRoutes(Layer.mergeAll(HealthRouter, ChatSessionsRouter)) +const RawRoutes = rawRoutes(ChatSessionsRouter) /** * The decision model the triage route asks, built once per isolate from the diff --git a/apps/ai/src/worker/bindings.ts b/apps/ai/src/worker/bindings.ts index 26e4e0d56a..3afac64d26 100644 --- a/apps/ai/src/worker/bindings.ts +++ b/apps/ai/src/worker/bindings.ts @@ -13,7 +13,7 @@ import { AuditEventsQueue, MapleDb, parseMapleDeployment } from "@maple/infra/cl import * as Cloudflare from "@alchemy.run/cloudflare" import { dischargeRuntimeContext } from "@maple/backend/platform/alchemy-runtime" import { Stage } from "alchemy/Stage" -import { Effect, Layer, Option } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { securityAuditWorkflowControl } from "../security-audit/workflow-control" import { isWorkersAiBinding, viaGateway, WorkersAiGateway } from "../platform/WorkersAiHttpClient" import { @@ -36,7 +36,7 @@ import { } from "@maple/backend/services/auth/McpToolRateLimiter" import { DATA_SOURCE_CALL_LIMITS } from "@maple/backend/services/data-sources/DataSourceCallBudgetLive" -export const bindAiClients = Effect.gen(function* () { +const bindClients = Effect.gen(function* () { // `MAPLE_DB` in the stage's flavor. The agents read and write the same // application database api does — investigations, error issues, dashboards — // so this is a connection budget of its own, not a share of api's. @@ -67,7 +67,27 @@ export const bindAiClients = Effect.gen(function* () { } }) -type AiBindingClients = Effect.Success +type AiBindingClients = Effect.Success + +/** + * The bound clients as a service: the init and the chat Durable Object's activation both read + * them, and an alchemy binding reaches an activation only through a tag. One layer value, so the + * bindings attach once. + */ +export class BoundAiClients extends Context.Service()( + "@maple/ai/worker/BoundAiClients", +) { + static readonly layer = Layer.effect(this, bindClients).pipe( + Layer.provide([ + Cloudflare.Hyperdrive.ConnectBinding, + Cloudflare.Workers.RateLimitBinding, + Cloudflare.Queues.WriteQueueBinding, + ]), + ) +} + +/** The clients, for the Worker's init. */ +export const bindAiClients = Effect.service(BoundAiClients) /** * The AI Gateway Workers AI calls route through. The logical id is the api's, unchanged: the @@ -109,6 +129,7 @@ export const AiBindingsLive = Layer.mergeAll( Cloudflare.Workers.RateLimitBinding, Cloudflare.Queues.WriteQueueBinding, WorkersAiGatewayLive, + BoundAiClients.layer, ) const limiter = (client: Cloudflare.Workers.RateLimitClient): RateLimiter => ({ @@ -124,6 +145,19 @@ const limiter = (client: Cloudflare.Workers.RateLimitClient): RateLimiter => ({ ), }) +/** + * The ports a graph outside a request still needs: a chat turn or an approval in the Durable + * Object spends the same data-source call budget as `/mcp`, and queues its audit entries. + */ +export const aiSessionPorts = (clients: AiBindingClients) => + Layer.mergeAll( + Layer.succeed(DataSourceProductionCallLimit, limiter(clients.dataSourceProductionCallLimit)), + Layer.succeed(DataSourceDevelopmentCallLimit, limiter(clients.dataSourceDevelopmentCallLimit)), + Layer.succeed(AuditEventsQueueProducer, queueProducer(clients.auditEvents)), + ) + +export type AiSessionPortsLayer = ReturnType + /** * The ports the service graph depends on, plus the env-backed ports * (`envPorts`: the env itself, the `ConfigProvider`, the sandbox service binding) and the @@ -141,9 +175,7 @@ export const aiPorts = ( Layer.succeed(WorkersAiGateway, workersAi), Layer.succeed(SecurityAuditWorkflowStarter, securityAuditWorkflowControl(audits)), Layer.succeed(McpToolsRateLimit, limiter(clients.mcpToolsRateLimit)), - Layer.succeed(DataSourceProductionCallLimit, limiter(clients.dataSourceProductionCallLimit)), - Layer.succeed(DataSourceDevelopmentCallLimit, limiter(clients.dataSourceDevelopmentCallLimit)), - Layer.succeed(AuditEventsQueueProducer, queueProducer(clients.auditEvents)), + aiSessionPorts(clients), layerMapleDbConnection(env), envPorts(env), layerChatSessions(chatSessions, env),