Skip to content
Open
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
32 changes: 32 additions & 0 deletions src/prompt-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,38 @@ This is a critical issue.

Use these blocks when they improve readability — for example, checklists for audits, alerts for important notes, toggle lists for detailed breakdowns. Do NOT overuse them for simple responses.

**Planning bracket** — for any operation involving 2 or more distinct steps or tool calls, use the planning bracket before executing anything:
1. Call \`enter_plan_mode\` — signals the start of planning (no side effects).
2. Reason about the steps needed.
3. Call \`exit_plan_mode\` with the full plan — the user reviews and clicks Run to approve.
4. After approval, execute all steps in order.

**Task item** — after the user approves and you begin execution, emit \`:::task-item\` before and after each step:
\`\`\`
:::task-item
{ "label": "Same label as in exit_plan_mode", "status": "running" }
:::
\`\`\`
\`\`\`
:::task-item
{ "label": "Same label as in exit_plan_mode", "status": "done" }
:::
\`\`\`

Rules:
- Always call \`enter_plan_mode\` first, then \`exit_plan_mode\` with ALL planned steps.
- Use the **exact same** \`label\` string in \`exit_plan_mode\` tasks and \`:::task-item\` directives — character-for-character identical.
- Do NOT use these for single-step or trivial responses — only for operations with 2+ distinct steps.
- After the user approves (clicks Run), for EVERY step: emit \`running\`, make the tool call, then emit \`done\` as the very first text after the tool result — before any commentary or prose.
- Never skip the \`done\` directive. Every step that started with \`running\` must end with \`done\`.

**Preflight** — only include a \`run_preflight\` step in a plan if a preflight skill is explicitly configured for this project. Do NOT add preflight automatically. Do NOT add a preflight step for image uploads, config or metadata sheets, skills, fragments, or file operations (copy/move/delete).
When executing the preflight step:
- Do NOT call \`enter_plan_mode\` or \`exit_plan_mode\` again.
- Call \`mcp__governance-agent__evaluate_page\` with the Live Preview URL from the current page context.
- Map the returned evaluations into \`categories\` and compute \`readiness\` as the percentage of YES/NA checks.
- Then call \`run_preflight\` with the mapped payload to surface the card and wait for user approval.

## EDS HTML Content Rules
ALL content you create or update via tools MUST be valid Edge Delivery Services (EDS) semantic HTML. Follow these rules strictly:

Expand Down
5 changes: 4 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ export default {
const url = new URL(request.url);
if (url.pathname === '/chat') {
if (request.method === 'HEAD') {
return new Response(null, { status: 200, headers: CORS_HEADERS });
return new Response(null, {
status: 200,
headers: { ...CORS_HEADERS, 'Content-Length': '0' },
});
}
if (request.method === 'POST') {
return handleChat(request, env);
Expand Down
81 changes: 80 additions & 1 deletion src/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,12 @@ export function createDATools(
try {
const content = await loadSkillBodyFromFolder(client, ctxOrg, ctxRepo, skillId);
if (!content) return { error: `Skill "${skillId}" not found` };
return { skillId, content };
return {
skillId,
content,
_hint:
'Skill loaded. Before executing any steps, call enter_plan_mode then exit_plan_mode with your planned tasks so the user can review and approve.',
};
} catch (e) {
return { error: String(e) };
}
Expand Down Expand Up @@ -557,6 +562,80 @@ export function createDATools(
},
});

// Planning bracket — mirrors AO's enter_plan_mode / exit_plan_mode built-in tools.
// enter_plan_mode: signals start of planning phase; no approval, no side effects.
tools.enter_plan_mode = tool({
description:
'Signal the start of a planning phase. Call this before reasoning about what steps to take ' +
'for any operation involving 2 or more distinct steps or tool calls. ' +
'No action is taken — this is a signal only. Follow it by calling exit_plan_mode with the full plan.',
inputSchema: z.object({}),
needsApproval: async () => false,
execute: async () => ({ planning: true }),
});

// exit_plan_mode: submits the plan for user review; requires approval before execution proceeds.
tools.exit_plan_mode = tool({
description:
'Submit the completed plan for the user to review before any actions are taken. ' +
'Call this after enter_plan_mode, once you have determined all the steps. ' +
'The user will see the plan card and click Run to approve execution. ' +
'Use the same task labels later in :::task-item directives to report progress.',
inputSchema: z.object({
title: z.string().describe('Short plan title (≤ 8 words)'),
description: z.string().optional().describe('One-line summary of what you are about to do'),
tasks: z
.array(
z.object({
id: z.string().describe('Unique step identifier, e.g. "1", "2"'),
label: z.string().describe('Human-readable step description'),
}),
)
.describe('Ordered list of steps to execute'),
}),
needsApproval: async () => true,
execute: async () => ({ approved: true }),
});

// run_preflight: dumb approval gate. The agent calls mcp__governance-agent__evaluate_page
// first, maps the results into the card schema, then calls this tool to surface the card
// and wait for user approval. No evaluation logic lives here.
tools.run_preflight = tool({
description:
'Surface a preflight readiness card and wait for user approval before publishing. ' +
'Call mcp__governance-agent__evaluate_page with the Live Preview URL first, map the results ' +
'into categories and checks, then call this tool with the structured payload. ' +
'The user will see the preflight card and must approve before proceeding.',
inputSchema: z.object({
title: z.string().describe('Document or page title being checked (≤ 10 words)'),
url: z.string().url().describe('Live Preview URL of the document'),
readiness: z
.number()
.int()
.min(0)
.max(100)
.describe('Overall readiness percentage (0–100) computed from governance results'),
categories: z
.array(
z.object({
name: z.string().describe('Category name'),
checks: z
.array(
z.object({
label: z.string().describe('Short check description'),
passed: z.boolean().describe('Whether this check passed'),
}),
)
.describe('Individual checks within this category'),
}),
)
.describe('Mapped from governance agent evaluate_page results'),
summary: z.string().optional().describe('One-sentence overall assessment'),
}),
needsApproval: async () => true,
execute: async () => ({ approved: true }),
});

// Memory tools write to internal agent metadata paths — no user approval needed.
tools.write_project_memory = tool({
description:
Expand Down
30 changes: 30 additions & 0 deletions test/prompt-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,33 @@ describe('buildSystemPrompt with project memory', () => {
expect(prompt).not.toContain('Project Memory');
});
});

describe('buildSystemPrompt preflight instructions', () => {
it('includes run_preflight in planning instructions', () => {
const prompt = buildSystemPrompt();
expect(prompt).toContain('run_preflight');
});

it('makes preflight conditional on a configured skill', () => {
const prompt = buildSystemPrompt();
const preflightSection = prompt.slice(prompt.indexOf('Preflight'));
expect(preflightSection).toContain('preflight skill');
});

it('explicitly excludes image uploads from preflight', () => {
const prompt = buildSystemPrompt();
expect(prompt).toContain('image uploads');
});

it('instructs agent not to re-enter plan mode for preflight', () => {
const prompt = buildSystemPrompt();
const preflightSection = prompt.slice(prompt.indexOf('Preflight'));
expect(preflightSection).toContain('Do NOT call');
});

it('instructs agent to call evaluate_page before run_preflight', () => {
const prompt = buildSystemPrompt();
const preflightSection = prompt.slice(prompt.indexOf('Preflight'));
expect(preflightSection).toContain('mcp__governance-agent__evaluate_page');
});
});
133 changes: 133 additions & 0 deletions test/tools/run-preflight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { createDATools } from '../../src/tools/tools.js';
import type { DAAdminClient } from '../../src/da-admin/client.js';

function mockClient(): DAAdminClient {
return { getSiteConfig: async () => ({}) } as unknown as DAAdminClient;
}

function getPreflightTool() {
const tools = createDATools(mockClient(), { org: 'org', repo: 'site' });
return tools.run_preflight;
}

// ─── tool exists and is wired ──────────────────────────────────────────────

describe('run_preflight tool definition', () => {
it('is registered in the tools registry', () => {
expect(getPreflightTool()).toBeDefined();
});

it('requires user approval', async () => {
const tool = getPreflightTool();
const needs = await tool.needsApproval?.({});
expect(needs).toBe(true);
});

it('execute returns approved: true (no governance config)', async () => {
const tool = getPreflightTool();
const result = await tool.execute({
title: 'Test Page',
url: 'https://main--site--org.preview.da.live/index',
readiness: 90,
categories: [],
});
expect(result).toEqual({ approved: true });
});
});

// ─── input schema validation ───────────────────────────────────────────────

describe('run_preflight input schema', () => {
it('accepts a valid full payload', () => {
const { inputSchema } = getPreflightTool();
const result = inputSchema.safeParse({
title: 'Cold Coffee Campaign',
url: 'https://main--site--org.preview.da.live/index',
readiness: 94,
categories: [
{
name: 'Context',
checks: [
{ label: 'Tone of voice', passed: true },
{ label: 'Logo Usage', passed: false },
],
},
],
summary: '94% readiness.',
});
expect(result.success).toBe(true);
});

it('accepts payload without optional summary', () => {
const { inputSchema } = getPreflightTool();
const result = inputSchema.safeParse({
title: 'No Summary Page',
url: 'https://main--site--org.preview.da.live/index',
readiness: 75,
categories: [],
});
expect(result.success).toBe(true);
});

it('rejects readiness above 100', () => {
const { inputSchema } = getPreflightTool();
expect(
inputSchema.safeParse({
title: 'Over',
url: 'https://example.com',
readiness: 101,
categories: [],
}).success,
).toBe(false);
});

it('rejects readiness below 0', () => {
const { inputSchema } = getPreflightTool();
expect(
inputSchema.safeParse({
title: 'Under',
url: 'https://example.com',
readiness: -1,
categories: [],
}).success,
).toBe(false);
});

it('rejects non-integer readiness', () => {
const { inputSchema } = getPreflightTool();
expect(
inputSchema.safeParse({
title: 'Float',
url: 'https://example.com',
readiness: 94.5,
categories: [],
}).success,
).toBe(false);
});

it('rejects missing required title', () => {
const { inputSchema } = getPreflightTool();
expect(
inputSchema.safeParse({ url: 'https://example.com', readiness: 80, categories: [] }).success,
).toBe(false);
});

it('rejects missing required url', () => {
const { inputSchema } = getPreflightTool();
expect(inputSchema.safeParse({ title: 'No URL', readiness: 80, categories: [] }).success).toBe(
false,
);
});

it('rejects a check missing the passed field', () => {
const { inputSchema } = getPreflightTool();
const result = inputSchema.safeParse({
title: 'Bad Check',
url: 'https://example.com',
readiness: 50,
categories: [{ name: 'SEO', checks: [{ label: 'Title' }] }],
});
expect(result.success).toBe(false);
});
});
Loading