diff --git a/context/architecture-context.md b/context/architecture-context.md index ab94ec9..c05c7b3 100644 --- a/context/architecture-context.md +++ b/context/architecture-context.md @@ -33,15 +33,19 @@ - **One turn = one Trigger.dev run** (`design-agent`). Routes validate input and access, trigger the run, and return `202`; they never call the model. - **A turn** reads the Liveblocks room read-only and summarises it (`lib/ai/canvas-summary.ts`), then analyzes the - message (updates the requirements brief and decides ask / plan / generate), drafts or revises a plan, or - generates the diagram for the approved plan, validates it (`lib/ai/graph-validation.ts`), makes at most one repair + message (updates the requirements brief and decides ask / plan / generate / reply / unsupported), drafts or + revises a plan, answers in text, or generates the diagram for the approved plan, validates it (`lib/ai/graph-validation.ts`), makes at most one repair call, and writes it with `mutateFlow`. -- **Code enforces the rules** the model is asked to follow (question rounds, skip, no generating without a plan) and +- **Code enforces the rules** the model is asked to follow (question rounds, skip, no generating without a plan, text-only + answers only for typed messages) and fills in everything render-related: node and edge types, sizes, fills from `NODE_ROLES`, dashed async edges, connection points, and layout. Existing components are referenced by `ex-N` refs; generated duplicates fold into existing nodes. +- **Add-only.** The agent never removes, merges, renames, or moves existing components. `reply` answers questions and + off-topic messages in text; `unsupported` (edit requests) and a plan with nothing new to add return a fixed message + written in code. Both settle as a `TEXT` message and leave the session phase and brief unchanged. - **Turns settle on read.** The turns route stores a `PENDING` reply with the run id; reading the session retrieves - the run, validates its output with zod, and stores `QUESTIONS`, `PLAN`, or `RESULT`. The task has no database + the run, validates its output with zod, and stores `QUESTIONS`, `PLAN`, `RESULT`, or `TEXT`. The task has no database access and never writes blobs. - **Reliability:** per-call timeouts (analyze 90s, plan 150s, generate 120s), task `maxDuration` 300s, a single task attempt, one retry per call on a schema mismatch, list limits trimmed in code instead of `maxItems`, and friendly diff --git a/context/progress-tracker.md b/context/progress-tracker.md index b428e91..86f5049 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -1560,3 +1560,20 @@ Update this file whenever the current phase, active feature, or implementation s - `lib/prisma.ts` Accelerate URL check fixed (`prisma+postgres://`). - Validation: `pnpm typecheck` and eslint on changed files passed; sanitizer checked on empty and partial drafts. Not yet verified: an "add components" AI turn in production after the Trigger.dev deploy. + +### AI agent: text replies and unsupported edits (2026-09-16) + +- Problem seen in production: "simplify the graph" and "what is my name?" both ended as a red error ("The plan didn't + include any new components…"), because the analyze step could only ask, plan, or generate and the agent is add-only. +- `TURN_DECISIONS` gains `reply` (questions about the diagram or plan, greetings, off-topic) and `unsupported` + (remove / merge / rename / move / restyle / simplify existing components, even mixed with additions). + `enforceDecision` turns both into `plan` for `answers` and `skip` turns. +- New `reply` result action: the answer (or `UNSUPPORTED_EDIT_MESSAGE`, written in code) is stored as a `TEXT` + message with no phase or brief change, so a proposed plan stays generatable. A plan with nothing to add now returns + a `reply` instead of aborting, so it no longer shows as an error; the passthrough stays for runs on older task + versions. These turns make no extra model call. +- Validation: `pnpm typecheck` and eslint passed; decision rules checked offline; live analyze on + `gemini-3.5-flash-lite` with a 10-component CI/CD canvas classified all five test messages as expected + (simplify → unsupported, name → reply, "why a separate CD Deployer" → reply, add Slack notifier → plan, + remove + add → unsupported). +- Still to do: editing existing components (a separate feature) and layout quality. diff --git a/lib/ai/agent-schema.ts b/lib/ai/agent-schema.ts index a2ac8e6..84f02cb 100644 --- a/lib/ai/agent-schema.ts +++ b/lib/ai/agent-schema.ts @@ -80,7 +80,11 @@ export const clarifyQuestionSchema = z.object({ export type ClarifyQuestion = z.infer; -export const TURN_DECISIONS = ["ask", "plan", "generate"] as const; +/** + * - `reply`: a question or remark that needs no design change; answered in text + * - `unsupported`: an edit the agent cannot make yet (remove, merge, rename, rearrange) + */ +export const TURN_DECISIONS = ["ask", "plan", "generate", "reply", "unsupported"] as const; export type TurnDecision = (typeof TURN_DECISIONS)[number]; @@ -91,11 +95,16 @@ export const turnAnalysisSchema = z.object({ .enum(TURN_DECISIONS) .describe( "'ask' to gather missing requirements, 'plan' to propose or revise a plan, " + - "'generate' only when a plan exists and the user approved it.", + "'generate' only when a plan exists and the user approved it, " + + "'reply' to answer a question or remark that needs no design change, " + + "'unsupported' when the user wants existing components removed, merged, renamed, or rearranged.", ), reply: z .string() - .describe("One or two plain sentences to the user. No markdown, and do not restate the questions."), + .describe( + "Plain text to the user, no markdown. One or two sentences, and do not restate the questions; " + + "for 'reply', the full answer in at most four sentences.", + ), questions: z .array(clarifyQuestionSchema) .describe( @@ -171,6 +180,15 @@ export type DesignPlanDraft = z.infer; export const PLAN_NOTHING_TO_ADD_MESSAGE = "The plan didn't include any new components to add. Say which components you want added or changed."; +/** Shown for edits the agent cannot make yet; written in code so it never promises more than it can do. */ +export const UNSUPPORTED_EDIT_MESSAGE = + "I can add new components and connections to this diagram, but I can't remove, merge, rename, or rearrange " + + "existing ones yet. You can make those edits directly on the canvas, or tell me what to add."; + +/** Stands in for an empty answer to a `reply` turn. */ +export const REPLY_FALLBACK_MESSAGE = + "I'm here to help design this system. Tell me what you'd like to add, or ask about the diagram."; + /** Output of one design-agent run. The server validates it before storing it. */ export const designAgentResultSchema = z.discriminatedUnion("action", [ z.object({ @@ -193,6 +211,11 @@ export const designAgentResultSchema = z.discriminatedUnion("action", [ decisions: z.array(planDecisionSchema), brief: designBriefSchema, }), + // A text answer: no plan, no canvas change, and the session's brief and phase stay as they were. + z.object({ + action: z.literal("reply"), + reply: z.string().min(1), + }), ]); export type DesignAgentResult = z.infer; diff --git a/lib/ai/design-agent-engine.ts b/lib/ai/design-agent-engine.ts index 2bc68af..0a8e9b7 100644 --- a/lib/ai/design-agent-engine.ts +++ b/lib/ai/design-agent-engine.ts @@ -62,11 +62,16 @@ function toMessages(payload: DesignAgentPayload): ModelMessage[] { /** * Applies the rules the model is asked to follow but cannot be trusted to: * no questions past the round limit or after a skip, no generating without a - * plan, no "ask" without questions. + * plan, no "ask" without questions, and no text-only answer to a turn that + * answers questions or skips them. */ export function enforceDecision(analysis: TurnAnalysis, payload: DesignAgentPayload): TurnAnalysis { let { decision } = analysis; + if ((decision === "reply" || decision === "unsupported") && payload.intent !== "message") { + decision = "plan"; + } + if (decision === "ask") { const mayAsk = payload.intent !== "skip" && diff --git a/lib/ai/prompts.ts b/lib/ai/prompts.ts index b653e32..fb1326f 100644 --- a/lib/ai/prompts.ts +++ b/lib/ai/prompts.ts @@ -25,8 +25,15 @@ export const ANALYZE_SYSTEM_PROMPT = [ ` - "plan": the brief is good enough to design, or the user asked to change an existing plan.`, ` - "generate": only when a plan already exists and the user's latest message approves it`, " without asking for changes.", + ` - "reply": the message needs no change to the design — a question about the diagram, the plan,`, + " or a choice in it, a general question, a greeting, or anything unrelated to the design.", + " Answer it in the reply. You know nothing about the user beyond this conversation.", + ` - "unsupported": the user wants existing components or connections removed, merged, renamed,`, + " moved, restyled, rearranged, or simplified. You can only add to a design, so choose this", + " even when the request also asks for additions.", " A detailed first message can go straight to a plan; a vague one should get questions.", "3. Write a short reply: one or two plain sentences, no markdown, no restating the questions.", + ` For "reply", give the full answer in at most four plain sentences.`, "", "The canvas may already hold a design; its contents are listed below. Treat the user's request as", "a change to that design unless they ask for a separate system: do not ask about anything the", diff --git a/lib/ai/session-turns.ts b/lib/ai/session-turns.ts index c421f2c..7586693 100644 --- a/lib/ai/session-turns.ts +++ b/lib/ai/session-turns.ts @@ -131,6 +131,7 @@ function describeFailedRun(runId: string, message: string | undefined): string { return describeRunFailure(message, { generic: GENERIC_RUN_ERROR, timeout: "The design agent took too long to respond. Try again.", + // Runs on task versions before the `reply` action aborted with this message. passthrough: [PLAN_NOTHING_TO_ADD_MESSAGE], }); } @@ -165,6 +166,14 @@ function outcomeFromResult(result: DesignAgentResult): TurnOutcome { phase: "COMPLETE", brief: result.brief, }; + case "reply": + // No phase or brief: a text answer leaves the session where it was, so a proposed plan stays generatable. + return { + kind: "TEXT", + status: "COMPLETE", + content: result.reply, + payload: { reply: result.reply }, + }; } } diff --git a/src/trigger/design-agent.ts b/src/trigger/design-agent.ts index bcf5dfe..fd065bf 100644 --- a/src/trigger/design-agent.ts +++ b/src/trigger/design-agent.ts @@ -1,9 +1,11 @@ import { mutateFlow } from "@liveblocks/react-flow/node"; -import { AbortTaskRunError, logger, metadata, task } from "@trigger.dev/sdk/v3"; +import { logger, metadata, task } from "@trigger.dev/sdk/v3"; import type { LanguageModel } from "ai"; import { PLAN_NOTHING_TO_ADD_MESSAGE, + REPLY_FALLBACK_MESSAGE, + UNSUPPORTED_EDIT_MESSAGE, type DesignAgentPayload, type DesignAgentResult, type DesignBrief, @@ -156,6 +158,17 @@ export const designAgentTask = task({ return { action: "ask", reply: analysis.reply, questions: analysis.questions, brief: analysis.brief }; } + // Text-only turns end here: no plan call and no canvas change. + if (analysis.decision === "reply") { + setStage("done"); + return { action: "reply", reply: analysis.reply.trim() || REPLY_FALLBACK_MESSAGE }; + } + + if (analysis.decision === "unsupported") { + setStage("done"); + return { action: "reply", reply: UNSUPPORTED_EDIT_MESSAGE }; + } + if (analysis.decision === "generate" && payload.plan) { const counts = await drawPlan(model, payload.roomId, ctx.run.id, analysis.brief, payload.plan, canvas); setStage("done"); @@ -170,12 +183,14 @@ export const designAgentTask = task({ setStage("planning"); const plan = await draftPlan(model, analysis.brief, payload.plan, payload.input, canvas); + setStage("done"); if (!plan) { - throw new AbortTaskRunError(PLAN_NOTHING_TO_ADD_MESSAGE); + // Not a failure: the request just needed nothing new. Answered in text, like a reply turn. + logger.log("Plan drafted with no components to add"); + return { action: "reply", reply: PLAN_NOTHING_TO_ADD_MESSAGE }; } logger.log("Plan drafted", { components: plan.components.length, decisions: plan.decisions.length }); - setStage("done"); return { action: "plan", reply: analysis.reply, plan, brief: analysis.brief }; }, });