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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions context/architecture-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 26 additions & 3 deletions lib/ai/agent-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ export const clarifyQuestionSchema = z.object({

export type ClarifyQuestion = z.infer<typeof clarifyQuestionSchema>;

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];

Expand All @@ -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(
Expand Down Expand Up @@ -171,6 +180,15 @@ export type DesignPlanDraft = z.infer<typeof designPlanDraftSchema>;
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({
Expand All @@ -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<typeof designAgentResultSchema>;
Expand Down
7 changes: 6 additions & 1 deletion lib/ai/design-agent-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand Down
7 changes: 7 additions & 0 deletions lib/ai/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions lib/ai/session-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
});
}
Expand Down Expand Up @@ -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 },
};
}
}

Expand Down
21 changes: 18 additions & 3 deletions src/trigger/design-agent.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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 };
},
});
Loading