From b642682c11248b689a989fc1ef76ce7427fb9e96 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 25 Jun 2026 12:04:31 -0700 Subject: [PATCH 01/14] feat(shared): typed task prompts, AGENTS.md type, size-based blob storage Foundation for AGENTS.md prompt support (#732): typed task prompts ('task' | 'agents.md') with content-hash ids, size-based body storage (small bodies inline, large bodies uploaded to blob and referenced via contentBlobUrl), a prompt client/resolver, typed prompt-feature extraction, and request schema fields (agentsMd, agentsMdParentIds, agentsMdPromptId). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prompt-feature-provider-fs.ts | 10 +- .../prompt-feature-store.test.ts | 112 ++++++++++++++ .../prompt-features/prompt-feature-store.ts | 24 ++- packages/shared/src/schemas/prompt-feature.ts | 3 + packages/shared/src/schemas/request.ts | 10 ++ packages/shared/src/schemas/task-prompt.ts | 10 +- packages/shared/src/storage/blob-storage.ts | 22 +++ packages/shared/src/task-prompts/index.ts | 1 + .../src/task-prompts/prompt-client.test.ts | 45 ++++++ .../shared/src/task-prompts/prompt-client.ts | 42 ++++++ .../task-prompts/task-prompt-store.test.ts | 137 +++++++++++++++++- .../src/task-prompts/task-prompt-store.ts | 102 +++++++++++-- packages/shared/src/types/types.ts | 56 ++++++- 13 files changed, 545 insertions(+), 29 deletions(-) create mode 100644 packages/shared/src/prompt-features/prompt-feature-store.test.ts create mode 100644 packages/shared/src/task-prompts/prompt-client.test.ts create mode 100644 packages/shared/src/task-prompts/prompt-client.ts diff --git a/packages/shared/src/prompt-features/prompt-feature-provider-fs.ts b/packages/shared/src/prompt-features/prompt-feature-provider-fs.ts index c76574ace..44dc6be30 100644 --- a/packages/shared/src/prompt-features/prompt-feature-provider-fs.ts +++ b/packages/shared/src/prompt-features/prompt-feature-provider-fs.ts @@ -4,7 +4,7 @@ import { readFileSync, readdirSync, existsSync } from 'fs'; import { join } from 'path'; import { parse as parseYaml } from 'yaml'; -import { PromptFeatureConfig } from '../types/types.js'; +import { PromptFeatureConfig, PromptType, PROMPT_TYPES } from '../types/types.js'; import { PromptFeatureProvider } from './prompt-feature-provider.js'; /** @@ -47,9 +47,17 @@ export class FileSystemPromptFeatureProvider implements PromptFeatureProvider { throw new Error(`Missing or invalid 'prompt' field in ${file}`); } + // Normalize legacy 'task' to canonical 'select'; accept any known type. + const rawType = + typeof data.type === "string" && data.type === "task" + ? "select" + : data.type; const feature: PromptFeatureConfig = { id: data.id.trim(), prompt: data.prompt.trim(), + ...(typeof rawType === "string" && PROMPT_TYPES.includes(rawType as PromptType) + ? { type: rawType as PromptType } + : {}), }; if (this.registry.has(feature.id)) { diff --git a/packages/shared/src/prompt-features/prompt-feature-store.test.ts b/packages/shared/src/prompt-features/prompt-feature-store.test.ts new file mode 100644 index 000000000..b29faf4cc --- /dev/null +++ b/packages/shared/src/prompt-features/prompt-feature-store.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from "vitest"; +import { PromptFeatureStore } from "./prompt-feature-store.js"; +import type { PromptFeatureDocument } from "../types/types.js"; + +// ── Minimal in-memory collection ───────────────────────────────────────────── +// Supports the subset of query operators the store uses: `id`, `deletedAt` +// existence, and the `$or`/`type` existence filters for type scoping. + +function matchesType(doc: PromptFeatureDocument, clause: any): boolean { + if (clause.type !== undefined) { + if (typeof clause.type === "object" && clause.type.$exists === false) { + return doc.type === undefined; + } + return doc.type === clause.type; + } + return true; +} + +function createMockCollection() { + const docs: PromptFeatureDocument[] = []; + + return { + _docs: docs, + find(filter: any) { + let results = docs.filter((d) => { + if (filter.deletedAt && filter.deletedAt.$exists === false && d.deletedAt) return false; + if (filter.$or) { + if (!filter.$or.some((clause: any) => matchesType(d, clause))) return false; + } + return true; + }); + return { + sort() { return this; }, + toArray() { return Promise.resolve(results.map((d) => ({ ...d }))); }, + }; + }, + findOne(filter: any) { + const doc = docs.find((d) => { + if (filter.id && d.id !== filter.id) return false; + if (filter.deletedAt && filter.deletedAt.$exists === false && d.deletedAt) return false; + return true; + }); + return Promise.resolve(doc ? { ...doc } : null); + }, + insertOne(doc: PromptFeatureDocument) { + docs.push({ ...doc }); + return Promise.resolve({ insertedId: doc.id }); + }, + updateOne() { return Promise.resolve({ matchedCount: 1, modifiedCount: 1 }); }, + }; +} + +describe("PromptFeatureStore type support", () => { + let store: PromptFeatureStore; + let collection: ReturnType; + + beforeEach(() => { + collection = createMockCollection(); + store = new PromptFeatureStore(collection as any); + }); + + it("create persists the type field when provided", async () => { + const doc = await store.create({ id: "agents_terse", prompt: "Be terse", type: "agents.md" }); + expect(doc.type).toBe("agents.md"); + expect(collection._docs[0].type).toBe("agents.md"); + }); + + it("create omits type when not provided (backward compatible)", async () => { + const doc = await store.create({ id: "task_feat", prompt: "A task feature" }); + expect(doc.type).toBeUndefined(); + expect("type" in collection._docs[0]).toBe(false); + }); + + it("getAll({type: 'agents.md'}) returns only agents.md features", async () => { + await store.create({ id: "task_a", prompt: "p", type: "select" }); + await store.create({ id: "legacy", prompt: "p" }); // no type → select + await store.create({ id: "agents_b", prompt: "p", type: "agents.md" }); + + const result = await store.getAll({ type: "agents.md" }); + expect(result.map((f) => f.id)).toEqual(["agents_b"]); + }); + + it("getAll({type: 'select'}) includes legacy (untyped) features", async () => { + await store.create({ id: "task_a", prompt: "p", type: "select" }); + await store.create({ id: "legacy", prompt: "p" }); + await store.create({ id: "agents_b", prompt: "p", type: "agents.md" }); + + const result = await store.getAll({ type: "select" }); + expect(result.map((f) => f.id).sort()).toEqual(["legacy", "task_a"]); + }); + + it("getAll() with no filter returns all features", async () => { + await store.create({ id: "task_a", prompt: "p" }); + await store.create({ id: "agents_b", prompt: "p", type: "agents.md" }); + + const result = await store.getAll(); + expect(result).toHaveLength(2); + }); + + it("seed carries the type field for new features", async () => { + const inserted = await store.seed([ + { id: "agents_seed", prompt: "p", type: "agents.md" }, + { id: "task_seed", prompt: "p" }, + ]); + expect(inserted).toBe(2); + expect(collection._docs.find((d) => d.id === "agents_seed")?.type).toBe("agents.md"); + expect("type" in (collection._docs.find((d) => d.id === "task_seed") as any)).toBe(false); + }); +}); diff --git a/packages/shared/src/prompt-features/prompt-feature-store.ts b/packages/shared/src/prompt-features/prompt-feature-store.ts index 7fda78964..82e4a39a0 100644 --- a/packages/shared/src/prompt-features/prompt-feature-store.ts +++ b/packages/shared/src/prompt-features/prompt-feature-store.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { Collection } from 'mongodb'; -import { PromptFeatureConfig, PromptFeatureDocument } from '../types/types.js'; +import { Collection, Filter } from 'mongodb'; +import { PromptFeatureConfig, PromptFeatureDocument, PromptType } from '../types/types.js'; /** * MongoDB-backed prompt feature store for CRUD operations on prompt feature definitions. @@ -13,10 +13,19 @@ import { PromptFeatureConfig, PromptFeatureDocument } from '../types/types.js'; export class PromptFeatureStore { constructor(private collection: Collection) {} - /** List all active (non-deleted) prompt features */ - async getAll(): Promise { + /** List all active (non-deleted) prompt features, optionally filtered by type */ + async getAll(opts?: { type?: PromptType }): Promise { + const filter: Filter = { deletedAt: { $exists: false } }; + if (opts?.type) { + // Absent `type` is treated as 'select' for backward compatibility + // (legacy prompt features predate typing and target the scenario task). + filter.$or = + opts.type === "select" + ? [{ type: "select" }, { type: { $exists: false } }] + : [{ type: opts.type }]; + } return this.collection - .find({ deletedAt: { $exists: false } }) + .find(filter) .sort({ id: 1 }) .toArray(); } @@ -30,8 +39,9 @@ export class PromptFeatureStore { async create(input: { id: string; prompt: string; + type?: PromptType; }): Promise { - const { id, prompt } = input; + const { id, prompt, type } = input; // Validate ID format if (!/^[a-z0-9_-]+$/.test(id)) { @@ -49,6 +59,7 @@ export class PromptFeatureStore { const doc: PromptFeatureDocument = { id, prompt: prompt.trim(), + ...(type ? { type } : {}), createdAt: new Date(), }; @@ -102,6 +113,7 @@ export class PromptFeatureStore { await this.collection.insertOne({ id: config.id, prompt: config.prompt, + ...(config.type ? { type: config.type } : {}), createdAt: new Date(), } as any); inserted++; diff --git a/packages/shared/src/schemas/prompt-feature.ts b/packages/shared/src/schemas/prompt-feature.ts index bf4d96ec3..b42215007 100644 --- a/packages/shared/src/schemas/prompt-feature.ts +++ b/packages/shared/src/schemas/prompt-feature.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; +import { PromptTypeSchema } from "./task-prompt.js"; extendZodWithOpenApi(z); @@ -10,6 +11,7 @@ export const CreatePromptFeatureInputSchema = z .object({ id: z.string().regex(/^[a-z][a-z0-9_]*$/), prompt: z.string(), + type: PromptTypeSchema.optional(), }) .openapi("CreatePromptFeatureInput"); @@ -23,6 +25,7 @@ export const PromptFeatureResponseSchema = z .object({ id: z.string(), prompt: z.string(), + type: PromptTypeSchema.optional(), createdAt: z.coerce.date(), updatedAt: z.coerce.date().optional(), deletedAt: z.coerce.date().optional(), diff --git a/packages/shared/src/schemas/request.ts b/packages/shared/src/schemas/request.ts index 513e54996..4cc338ee3 100644 --- a/packages/shared/src/schemas/request.ts +++ b/packages/shared/src/schemas/request.ts @@ -129,6 +129,14 @@ export const CreateRequestInputSchema = z profileId: z.string().optional(), profileVariations: z.array(z.string()).optional(), priority: z.number().int().optional(), + // Raw AGENTS.md body to deliver to the worker workspace. When present, the + // API findOrCreates an `agents.md`-typed prompt and stores its id on the + // request (see `agentsMdPromptId`). + agentsMd: z.string().optional(), + // Lineage edges (parent AGENTS.md prompt ids) when this candidate was + // derived from earlier ones: [] / omitted = root, [p] = mutation, + // [i, j] = merge of two parents. + agentsMdParentIds: z.array(z.string()).optional(), gates: z.array(GateConfigSchema).optional(), }) .openapi("CreateRequestInput"); @@ -147,6 +155,8 @@ export const RequestResponseSchema = z persona: PersonaSchema.optional(), deletedAt: z.coerce.date().optional(), taskPromptId: z.string().optional(), + agentsMdPromptId: z.string().optional(), + agentsMdParentIds: z.array(z.string()).optional(), mcpServers: z.array(z.string()).optional(), skillRevisions: z.array(z.string()).optional(), codebaseRevisionId: z.string().optional(), diff --git a/packages/shared/src/schemas/task-prompt.ts b/packages/shared/src/schemas/task-prompt.ts index 71f3fb053..bfbc8c05f 100644 --- a/packages/shared/src/schemas/task-prompt.ts +++ b/packages/shared/src/schemas/task-prompt.ts @@ -4,11 +4,11 @@ import { z } from "zod"; import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; import { PromptFeatureResultSchema } from "./prompt-feature.js"; -import { GATES } from "../types/types.js"; +import { PROMPT_TYPES } from "../types/types.js"; extendZodWithOpenApi(z); -export const PromptTypeSchema = z.enum(GATES); +export const PromptTypeSchema = z.enum(PROMPT_TYPES).openapi("PromptType"); export const CreateTaskPromptInputSchema = z .object({ @@ -21,7 +21,11 @@ export const TaskPromptResponseSchema = z .object({ _id: z.string(), type: PromptTypeSchema.optional(), - text: z.string(), + // Present for inline-stored bodies; absent when the body lives in blob + // storage (see `contentBlobUrl`). Use the `/content` endpoint to always + // get the resolved text. + text: z.string().optional(), + contentBlobUrl: z.string().optional(), features: z.array(PromptFeatureResultSchema).optional(), featuresExtractedAt: z.coerce.date().optional(), createdAt: z.coerce.date(), diff --git a/packages/shared/src/storage/blob-storage.ts b/packages/shared/src/storage/blob-storage.ts index 19a7e3db2..c9b50038b 100644 --- a/packages/shared/src/storage/blob-storage.ts +++ b/packages/shared/src/storage/blob-storage.ts @@ -438,6 +438,28 @@ export class BlobStorage { return blockBlobClient.url; } + /** + * Uploads an in-memory UTF-8 text body to the snapshots container as a single + * block blob. Overwrites if the blob already exists. Returns the blob URL. + * + * Use this for plain-text/markdown bodies (e.g. large prompt or AGENTS.md + * content) where the JSON content-type from `uploadJson` would be misleading. + */ + async uploadText( + blobName: string, + text: string, + contentType: string = "text/plain; charset=utf-8", + ): Promise { + await this.ensureContainer(); + + const buf = Buffer.from(text, "utf-8"); + const blockBlobClient = this.containerClient.getBlockBlobClient(blobName); + await blockBlobClient.uploadData(buf, { + blobHTTPHeaders: { blobContentType: contentType }, + }); + return blockBlobClient.url; + } + /** * Downloads a snapshot from blob storage and extracts it to the target directory. */ diff --git a/packages/shared/src/task-prompts/index.ts b/packages/shared/src/task-prompts/index.ts index b00d0570c..0cb7cb5e9 100644 --- a/packages/shared/src/task-prompts/index.ts +++ b/packages/shared/src/task-prompts/index.ts @@ -3,3 +3,4 @@ export * from "./task-prompt-id.js"; export * from "./task-prompt-store.js"; +export * from "./prompt-client.js"; diff --git a/packages/shared/src/task-prompts/prompt-client.test.ts b/packages/shared/src/task-prompts/prompt-client.test.ts new file mode 100644 index 000000000..34100ba3b --- /dev/null +++ b/packages/shared/src/task-prompts/prompt-client.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { PromptClient } from "./prompt-client.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PromptClient.getText", () => { + it("fetches resolved text from the content endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: "p1", text: "# AGENTS\nBe concise." }), + }); + vi.stubGlobal("fetch", fetchMock); + + const client = new PromptClient("https://api.example.com/"); + const text = await client.getText("p1"); + + expect(text).toBe("# AGENTS\nBe concise."); + // Trailing slash on base URL is trimmed; id is path-encoded. + expect(fetchMock).toHaveBeenCalledWith("https://api.example.com/api/v1/task-prompts/p1/content"); + }); + + it("throws a clear error on 404", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: "Not Found" })); + const client = new PromptClient("https://api.example.com"); + await expect(client.getText("missing")).rejects.toThrow(/not found/i); + }); + + it("throws on non-OK HTTP status", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" })); + const client = new PromptClient("https://api.example.com"); + await expect(client.getText("p1")).rejects.toThrow(/500/); + }); + + it("throws when the response lacks a text field", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: "p1" }) })); + const client = new PromptClient("https://api.example.com"); + await expect(client.getText("p1")).rejects.toThrow(/no text/i); + }); +}); diff --git a/packages/shared/src/task-prompts/prompt-client.ts b/packages/shared/src/task-prompts/prompt-client.ts new file mode 100644 index 000000000..ade51cc2f --- /dev/null +++ b/packages/shared/src/task-prompts/prompt-client.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Client for resolving prompt text (task or AGENTS.md) via the Scope REST API. + * + * Used by queue processors at message-processing time to fetch the resolved + * plain-text body of a prompt (inline or blob-backed) without needing direct + * blob storage access. The API endpoint `GET /api/v1/task-prompts/:id/content` + * runs `resolvePromptText` server-side and returns `{ id, text }`. + */ +export class PromptClient { + private readonly apiUrl: string; + + constructor(apiUrl: string) { + this.apiUrl = apiUrl.replace(/\/+$/, ""); + } + + /** + * Fetch the resolved plain-text body of a prompt by id. + * + * @param id - Prompt id (content hash) of a task or agents.md prompt. + * @throws Error if the prompt cannot be resolved (404 or HTTP error). + */ + async getText(id: string): Promise { + const url = `${this.apiUrl}/api/v1/task-prompts/${encodeURIComponent(id)}/content`; + const res = await fetch(url); + + if (res.status === 404) { + throw new Error(`Prompt '${id}' not found via API`); + } + if (!res.ok) { + throw new Error(`[PromptClient] GET ${url} failed: ${res.status} ${res.statusText}`); + } + + const data = (await res.json()) as { id: string; text: string }; + if (typeof data.text !== "string") { + throw new Error(`[PromptClient] GET ${url} returned no text for prompt '${id}'`); + } + return data.text; + } +} diff --git a/packages/shared/src/task-prompts/task-prompt-store.test.ts b/packages/shared/src/task-prompts/task-prompt-store.test.ts index 930846978..a0521e807 100644 --- a/packages/shared/src/task-prompts/task-prompt-store.test.ts +++ b/packages/shared/src/task-prompts/task-prompt-store.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { TaskPromptStore } from "./task-prompt-store.js"; -import { computeTaskPromptId } from "./task-prompt-id.js"; +import { computeTaskPromptId, computePromptId } from "./task-prompt-id.js"; import type { TaskPromptDocument, PromptFeatureResult } from "../types/types.js"; // ── Mock Collection ────────────────────────────────────────────────────────── @@ -63,9 +63,10 @@ function createMockCollection() { let count = 0; for (const doc of docs.values()) { if (filter.deletedAt?.$exists === false && doc.deletedAt) continue; + if (!matchesType(filter, doc)) continue; if (filter.text?.$regex) { const regex = new RegExp(filter.text.$regex, filter.text.$options); - if (!regex.test(doc.text)) continue; + if (!regex.test(doc.text ?? "")) continue; } count++; } @@ -76,9 +77,10 @@ function createMockCollection() { const results: TaskPromptDocument[] = []; for (const doc of docs.values()) { if (filter.deletedAt?.$exists === false && doc.deletedAt) continue; + if (!matchesType(filter, doc)) continue; if (filter.text?.$regex) { const regex = new RegExp(filter.text.$regex, filter.text.$options); - if (!regex.test(doc.text)) continue; + if (!regex.test(doc.text ?? "")) continue; } results.push({ ...doc }); } @@ -87,6 +89,27 @@ function createMockCollection() { }; } +/** Replicates the store's `$or`/`type` filter against an in-memory doc. */ +function matchesType(filter: any, doc: TaskPromptDocument): boolean { + if (filter.$or) { + return filter.$or.some((clause: any) => { + if (clause.type?.$exists === false) return doc.type === undefined; + return doc.type === clause.type; + }); + } + if (filter.type !== undefined) { + const cond = filter.type; + if (cond && typeof cond === "object") { + // `$nin` (and `$in`) treat a missing `type` as `undefined` — mirrors Mongo, + // so legacy untyped docs stay visible under the default `$nin` filter. + if (Array.isArray(cond.$nin)) return !cond.$nin.includes(doc.type); + if (Array.isArray(cond.$in)) return cond.$in.includes(doc.type); + } + return doc.type === cond; + } + return true; +} + // ── Tests ──────────────────────────────────────────────────────────────────── describe("TaskPromptStore", () => { @@ -131,6 +154,98 @@ describe("TaskPromptStore", () => { expect(revived._id).toBe(doc._id); expect(revived.deletedAt).toBeUndefined(); }); + + it("namespaces non-task types into a distinct id", async () => { + const task = await store.findOrCreate("same text"); + const agents = await store.findOrCreate("same text", "agents.md"); + expect(agents._id).not.toBe(task._id); + expect(agents._id).toBe(computePromptId("agents.md", "same text")); + expect(agents.type).toBe("agents.md"); + }); + + it("keeps task ids backward-compatible (unchanged by type arg)", async () => { + const a = await store.findOrCreate("hello"); + const b = await store.findOrCreate("hello", "select"); + expect(a._id).toBe(b._id); + expect(a._id).toBe(computeTaskPromptId("hello")); + }); + }); + + // -- size-based storage --------------------------------------------------- + + describe("size-based storage", () => { + function createMockBlob() { + const blobs = new Map(); + return { + store: blobs, + uploadText: vi.fn(async (name: string, text: string) => { + blobs.set(name, text); + return `https://acct.blob/snapshots/${name}`; + }), + downloadBlobToBuffer: vi.fn(async (name: string) => { + const v = blobs.get(name); + if (v === undefined) throw new Error(`blob ${name} not found`); + return Buffer.from(v, "utf-8"); + }), + }; + } + + it("stores small bodies inline and never touches blob", async () => { + const blob = createMockBlob(); + const s = new TaskPromptStore(col as any, blob as any, 1024); + const doc = await s.findOrCreate("tiny"); + expect(doc.text).toBe("tiny"); + expect(doc.contentBlobUrl).toBeUndefined(); + expect(blob.uploadText).not.toHaveBeenCalled(); + }); + + it("uploads over-threshold bodies to blob on a create miss", async () => { + const blob = createMockBlob(); + const s = new TaskPromptStore(col as any, blob as any, 16); + const big = "x".repeat(64); + const doc = await s.findOrCreate(big, "agents.md"); + expect(doc.text).toBeUndefined(); + expect(doc.contentBlobUrl).toContain(`prompts/${doc._id}.txt`); + expect(blob.uploadText).toHaveBeenCalledTimes(1); + }); + + it("skips the upload on a hit (idempotent)", async () => { + const blob = createMockBlob(); + const s = new TaskPromptStore(col as any, blob as any, 16); + const big = "y".repeat(64); + await s.findOrCreate(big, "agents.md"); + await s.findOrCreate(big, "agents.md"); + expect(blob.uploadText).toHaveBeenCalledTimes(1); + }); + + it("resolvePromptText returns inline text without blob access", async () => { + const blob = createMockBlob(); + const s = new TaskPromptStore(col as any, blob as any, 1024); + const doc = await s.findOrCreate("inline body"); + expect(await s.resolvePromptText(doc)).toBe("inline body"); + expect(blob.downloadBlobToBuffer).not.toHaveBeenCalled(); + }); + + it("resolvePromptText downloads blob-backed bodies", async () => { + const blob = createMockBlob(); + const s = new TaskPromptStore(col as any, blob as any, 16); + const big = "z".repeat(64); + const doc = await s.findOrCreate(big, "agents.md"); + expect(await s.resolvePromptText(doc)).toBe(big); + expect(blob.downloadBlobToBuffer).toHaveBeenCalledTimes(1); + }); + + it("rejects bodies over the hard max size", async () => { + const blob = createMockBlob(); + const s = new TaskPromptStore(col as any, blob as any, 16); + const huge = "a".repeat(256 * 1024 + 1); + await expect(s.findOrCreate(huge)).rejects.toThrow(/maximum/); + }); + + it("throws when an over-threshold body has no blob storage configured", async () => { + const s = new TaskPromptStore(col as any, undefined, 16); + await expect(s.findOrCreate("b".repeat(64))).rejects.toThrow(/BlobStorage/); + }); }); // -- get ------------------------------------------------------------------ @@ -222,6 +337,22 @@ describe("TaskPromptStore", () => { expect(total).toBe(1); expect(items[0].text).toBe("Azure deployment"); }); + + it("defaults to task-typed prompts (and legacy untyped docs)", async () => { + await store.findOrCreate("a task prompt"); + await store.findOrCreate("an agents file", "agents.md"); + const { items, total } = await store.getAll(); + expect(total).toBe(1); + expect(items[0].text).toBe("a task prompt"); + }); + + it("filters by agents.md type", async () => { + await store.findOrCreate("a task prompt"); + await store.findOrCreate("an agents file", "agents.md"); + const { items, total } = await store.getAll({ type: "agents.md" }); + expect(total).toBe(1); + expect(items[0].text).toBe("an agents file"); + }); }); // -- attachFeatures ------------------------------------------------------- diff --git a/packages/shared/src/task-prompts/task-prompt-store.ts b/packages/shared/src/task-prompts/task-prompt-store.ts index 6996df3b8..93e41a61f 100644 --- a/packages/shared/src/task-prompts/task-prompt-store.ts +++ b/packages/shared/src/task-prompts/task-prompt-store.ts @@ -2,25 +2,52 @@ // Licensed under the MIT License. import { Collection } from 'mongodb'; -import { TaskPromptDocument, PromptFeatureResult, PromptType } from '../types/types.js'; +import { TaskPromptDocument, PromptFeatureResult, PromptType, NON_GATE_PROMPT_TYPES } from '../types/types.js'; import { computePromptId } from './task-prompt-id.js'; +import type { BlobStorage } from '../storage/blob-storage.js'; + +/** Default inline-vs-blob threshold (UTF-8 bytes). Bodies larger than this are + * stored in blob storage; smaller bodies stay inline in Mongo. */ +export const DEFAULT_PROMPT_INLINE_MAX_BYTES = 16 * 1024; + +/** Hard guard cap on prompt body size (UTF-8 bytes). Bodies larger than this + * are rejected outright, regardless of storage location. */ +export const PROMPT_MAX_BYTES = 256 * 1024; + +/** Deterministic blob path for a prompt body that lives in blob storage. */ +export function promptBlobName(id: string): string { + return `prompts/${id}.txt`; +} /** * MongoDB-backed store for task prompt entities. * * Task prompts are **immutable and content-addressed**: the `_id` is a UUIDv5 - * derived from the trimmed prompt text (and, for non-Select gates, the prompt - * `type`). The same `(type, text)` always resolves to the same document — - * `findOrCreate` is idempotent. + * derived from the trimmed prompt text (and, for non-Select gates and non-gate + * kinds like `agents.md`, the prompt `type`). The same `(type, text)` always + * resolves to the same document — `findOrCreate` is idempotent. `type` defaults + * to `"select"` so existing task-prompt call sites are unaffected. * - * Prompts carry a `type` discriminator — one literal per gate (the Select gate's - * prompt is the request's task prompt). `type` defaults to `"select"` so - * existing task-prompt call sites are unaffected. + * The body is stored **inline** (`text`) when small, or in **blob storage** + * (`contentBlobUrl`) when it exceeds the configured inline threshold. Storage + * location is decided purely by size, independent of the prompt `type`. * * Documents are soft-deleted (deletedAt) rather than removed. */ export class TaskPromptStore { - constructor(private collection: Collection) {} + private readonly inlineMaxBytes: number; + + constructor( + private collection: Collection, + private blobStorage?: BlobStorage, + inlineMaxBytes?: number, + ) { + this.inlineMaxBytes = + inlineMaxBytes ?? + (process.env.PROMPT_INLINE_MAX_BYTES + ? parseInt(process.env.PROMPT_INLINE_MAX_BYTES, 10) + : DEFAULT_PROMPT_INLINE_MAX_BYTES); + } /** Get a single task prompt by ID (non-deleted) */ async get(id: string): Promise { @@ -32,10 +59,34 @@ export class TaskPromptStore { return this.get(computePromptId(type, text)); } + /** + * Resolve a prompt document's body to plain text, regardless of storage + * location: returns inline `text` when present, otherwise downloads the body + * from blob storage. Throws if the document has neither (corrupt) or if blob + * storage is needed but not configured. + */ + async resolvePromptText(doc: TaskPromptDocument): Promise { + if (doc.text != null) return doc.text; + if (doc.contentBlobUrl) { + if (!this.blobStorage) { + throw new Error( + `Task prompt '${doc._id}' body is in blob storage but no BlobStorage is configured`, + ); + } + const buf = await this.blobStorage.downloadBlobToBuffer(promptBlobName(doc._id)); + return buf.toString('utf-8'); + } + throw new Error(`Task prompt '${doc._id}' has neither inline text nor a blob reference`); + } + /** * Find an existing task prompt by `(type, text)`, or create a new one. * Idempotent — the same `(type, text)` always returns the same document. * `type` defaults to `"select"` (the request task prompt). + * + * Bodies over the inline threshold are uploaded to blob storage on a create + * **miss** (and the doc stores only a `contentBlobUrl`); smaller bodies are + * stored inline. The dedup/lookup is by content hash and never touches blob. */ async findOrCreate(text: string, type: PromptType = 'select'): Promise { const trimmed = text.trim(); @@ -62,14 +113,36 @@ export class TaskPromptStore { return existing; } - // Create new document + // Enforce the hard size cap before storing anywhere. + const byteLength = Buffer.byteLength(trimmed, 'utf-8'); + if (byteLength > PROMPT_MAX_BYTES) { + throw new Error( + `Prompt body is ${byteLength} bytes, exceeding the maximum of ${PROMPT_MAX_BYTES} bytes`, + ); + } + const doc: TaskPromptDocument = { _id: id, type, - text: trimmed, createdAt: new Date(), }; + // Size-based storage: large bodies go to blob, small bodies stay inline. + if (byteLength > this.inlineMaxBytes) { + if (!this.blobStorage) { + throw new Error( + `Prompt body is ${byteLength} bytes (over the ${this.inlineMaxBytes}-byte inline ` + + `threshold) but no BlobStorage is configured to store it`, + ); + } + doc.contentBlobUrl = await this.blobStorage.uploadText( + promptBlobName(id), + trimmed, + ); + } else { + doc.text = trimmed; + } + await this.collection.insertOne(doc as any); return doc; } @@ -77,7 +150,8 @@ export class TaskPromptStore { /** * List active (non-deleted) task prompts. * Supports pagination, optional substring search on text, and an optional - * `type` filter (one prompt type per gate). + * `type` filter. With no `type`, prompts of every type are returned; pass a + * `type` (e.g. `'select'` or `'agents.md'`) to scope the list. */ async getAll(opts?: { limit?: number; @@ -92,6 +166,12 @@ export class TaskPromptStore { } if (opts?.type) { filter.type = opts.type; + } else { + // The default (unfiltered) list shows all gate prompt types and legacy + // untyped docs, but hides non-gate types such as auto-generated + // 'agents.md' candidates, which can be high-volume noise. `$nin` + // also matches docs with no `type` field, so legacy prompts stay visible. + filter.type = { $nin: NON_GATE_PROMPT_TYPES }; } const total = await this.collection.countDocuments(filter); diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index 2e3c72eb3..c157f4d3b 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -62,11 +62,24 @@ export function isGateId(value: unknown): value is GateId { return typeof value === "string" && (GATES as readonly string[]).includes(value); } +/** Non-gate prompt kinds — prompt types that are not pipeline gates. */ +export const NON_GATE_PROMPT_TYPES = ["agents.md"] as const; + +/** Every prompt-type discriminator: the gates plus non-gate kinds. */ +export const PROMPT_TYPES = [...GATES, ...NON_GATE_PROMPT_TYPES] as const; + /** - * A prompt's type discriminator — one literal per gate. The prompt that drives - * a gate must have a `type` equal to that gate's id. + * A prompt's type discriminator. Either a gate id — the prompt that drives that + * gate, where `select` is the request's task prompt — or a non-gate kind such + * as `agents.md` (an AGENTS.md instruction file delivered into the agent + * workspace). Absent on a document ⇒ legacy `'select'`. */ -export type PromptType = GateId; +export type PromptType = (typeof PROMPT_TYPES)[number]; + +/** True when `value` is a valid prompt type (a gate id or a non-gate kind). */ +export function isPromptType(value: unknown): value is PromptType { + return typeof value === "string" && (PROMPT_TYPES as readonly string[]).includes(value); +} /** * Per-gate configuration on a request. Describes which prompt drives the gate, @@ -267,6 +280,19 @@ export interface RequestDocument { deletedAt?: Date; // Soft-delete timestamp (null/absent = active) taskPromptId?: string; // Materialized UUIDv5 of scenario.task (FK → TaskPromptDocument._id) promptFeatureExtractionId?: string; // @deprecated — use TaskPromptDocument.features via taskPromptId instead + /** + * FK → TaskPromptDocument._id of an AGENTS.md-typed prompt to deliver into + * the agent's workspace for this run. When set, the worker writes the + * resolved content to `/AGENTS.md` before the run starts. + */ + agentsMdPromptId?: string; + /** + * Lineage edges for the AGENTS.md candidate: the parent AGENTS.md prompt ids + * this candidate was derived from. Empty/absent = root (seed); one entry = + * reflective mutation; two entries = merge. Recorded on the request so the + * full lineage DAG can be reconstructed by querying related requests. + */ + agentsMdParentIds?: string[]; mcpServers?: string[]; // MCP server slugs selected for this run skillRevisions?: string[]; // Skill revision refs (e.g. "vercel-labs/agent-skills/my-skill@a1b2c3d") codebaseRevisionId?: string; // FK → CodebaseRevisionDocument._id — seeds the workspace before the agent starts @@ -735,8 +761,22 @@ export interface InsightDocument { /** Task prompt document stored in MongoDB. Immutable — text cannot be changed after creation. */ export interface TaskPromptDocument { _id: string; // UUIDv5 (content-addressed; see computePromptId) - type?: PromptType; // Which gate this prompt drives. Absent = legacy "select". - text: string; // Full task prompt text + /** + * Inline prompt body. Present when the body is small enough to store in + * Mongo (≤ PROMPT_INLINE_MAX_BYTES). Mutually exclusive with + * `contentBlobUrl` — exactly one is set. Optional so large bodies can live + * in blob storage instead. + */ + text?: string; + /** + * Blob reference to the prompt body when it exceeds the inline size + * threshold. Mutually exclusive with `text`. The body is fetched via + * `resolvePromptText` server-side. + */ + contentBlobUrl?: string; + /** Which gate this prompt drives, or a non-gate kind (e.g. `agents.md`). + * Absent ⇒ legacy `'select'` (the request's task prompt). */ + type?: PromptType; features?: PromptFeatureResult[]; // Detected prompt features featuresExtractedAt?: Date; // When features were last extracted createdAt: Date; @@ -751,6 +791,12 @@ export interface TaskPromptDocument { export interface PromptFeatureConfig { id: string; prompt: string; + /** + * Which prompt type this feature applies to. Absent ⇒ `'select'` (backward + * compatible). Feature extraction only considers features whose `type` + * matches the prompt being extracted. + */ + type?: PromptType; } /** Prompt feature document stored in MongoDB (extends PromptFeatureConfig with DB metadata) */ From 95b52f3b4fdd0de6b199306a20e012f9ca2e5759 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 25 Jun 2026 12:04:41 -0700 Subject: [PATCH 02/14] feat(api): AGENTS.md request handling and typed task-prompt/prompt-feature routes Wire AGENTS.md support into the API (#732): create-request accepts agentsMd (raw text) and findOrCreates an 'agents.md'-typed prompt, storing agentsMdPromptId and agentsMdParentIds; task-prompt and prompt-feature routes become type-aware and expose a prompt content endpoint that resolves inline or blob-backed bodies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/api/src/endpoints.test.ts | 174 +++++++++++++++++++++++++ apps/api/src/index.ts | 9 +- apps/api/src/routes/prompt-features.ts | 34 ++++- apps/api/src/routes/requests/index.ts | 38 +++++- apps/api/src/routes/task-prompts.ts | 53 +++++++- apps/api/src/test-helpers.ts | 1 + 6 files changed, 295 insertions(+), 14 deletions(-) diff --git a/apps/api/src/endpoints.test.ts b/apps/api/src/endpoints.test.ts index 0cabc44eb..6f20c3b77 100644 --- a/apps/api/src/endpoints.test.ts +++ b/apps/api/src/endpoints.test.ts @@ -945,6 +945,44 @@ describe("API Endpoints", () => { expect(res.status).toBe(400); }); + + it("persists type=agents.md when creating a feature", async () => { + (mocks.promptFeatureCollection.findOne as any).mockResolvedValue(null); + + const res = await request(app) + .post("/api/v1/prompt-features") + .send({ id: "agents_feat", prompt: "Does AGENTS.md mention tests?", type: "agents.md" }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("type", "agents.md"); + const doc = (mocks.promptFeatureCollection.insertOne as any).mock.calls[0][0]; + expect(doc).toHaveProperty("type", "agents.md"); + }); + }); + + describe("GET /api/v1/prompt-features?type=", () => { + it("scopes the query to agents.md features", async () => { + (mocks.promptFeatureCollection.find as any).mockReturnValue({ + toArray: vi.fn().mockResolvedValue([]), + }); + + const res = await request(app).get("/api/v1/prompt-features?type=agents.md"); + expect(res.status).toBe(200); + const filter = (mocks.promptFeatureCollection.find as any).mock.calls[0][0]; + expect(JSON.stringify(filter)).toContain("agents.md"); + }); + + it("treats select type as including legacy untyped features", async () => { + (mocks.promptFeatureCollection.find as any).mockReturnValue({ + toArray: vi.fn().mockResolvedValue([]), + }); + + await request(app).get("/api/v1/prompt-features?type=select"); + const filter = (mocks.promptFeatureCollection.find as any).mock.calls[0][0]; + const json = JSON.stringify(filter); + expect(json).toContain("$exists"); + expect(json).toContain("select"); + }); }); describe("GET /api/v1/prompt-features/:id", () => { @@ -1032,6 +1070,62 @@ describe("API Endpoints", () => { }); }); + // =================================================================== + // Typed task prompts (task | agents.md) + content resolution + // =================================================================== + + describe("Typed task prompts", () => { + it("passes ?type=agents.md through to the store getAll filter", async () => { + (mocks.taskPromptStore as any).getAll.mockResolvedValue({ items: [], total: 0 }); + + const res = await request(app).get("/api/v1/task-prompts?type=agents.md"); + expect(res.status).toBe(200); + const arg = (mocks.taskPromptStore.getAll as any).mock.calls[0][0]; + expect(arg).toHaveProperty("type", "agents.md"); + }); + + it("creates a prompt with type=agents.md", async () => { + (mocks.taskPromptStore as any).findOrCreate.mockResolvedValue({ + _id: "agents-1", + type: "agents.md", + text: "# AGENTS\nBe concise.", + createdAt: new Date(), + }); + + const res = await request(app) + .post("/api/v1/task-prompts") + .send({ text: "# AGENTS\nBe concise.", type: "agents.md" }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("type", "agents.md"); + const arg = (mocks.taskPromptStore.findOrCreate as any).mock.calls[0]; + expect(arg[0]).toBe("# AGENTS\nBe concise."); + expect(arg[1]).toBe("agents.md"); + }); + + it("GET /:id/content resolves the prompt body via resolvePromptText", async () => { + (mocks.taskPromptStore as any).get.mockResolvedValue({ + _id: "agents-1", + type: "agents.md", + contentBlobUrl: "https://blob/prompts/agents-1.txt", + createdAt: new Date(), + }); + (mocks.taskPromptStore as any).resolvePromptText.mockResolvedValue("resolved body"); + + const res = await request(app).get("/api/v1/task-prompts/agents-1/content"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: "agents-1", text: "resolved body" }); + expect(mocks.taskPromptStore.resolvePromptText).toHaveBeenCalled(); + }); + + it("GET /:id/content returns 404 when the prompt does not exist", async () => { + (mocks.taskPromptStore as any).get.mockResolvedValue(null); + + const res = await request(app).get("/api/v1/task-prompts/missing/content"); + expect(res.status).toBe(404); + }); + }); + // =================================================================== // Submit with profile — server-side field resolution // =================================================================== @@ -1239,6 +1333,86 @@ describe("API Endpoints", () => { }); }); + // =================================================================== + // AGENTS.md on request creation + // =================================================================== + + describe("POST /api/v1/requests?worker=... (AGENTS.md)", () => { + const setupCopilotAgent = () => { + (mocks.agentCollection.findOne as any).mockResolvedValue({ + _id: "coder-acp-copilot", + versions: [{ agentVersion: "v1", status: "active", queueName: "queue-coder-acp-copilot", createdAt: new Date() }], + supportedModels: ["claude-haiku-4.5"], + }); + }; + + it("resolves agentsMd via findOrCreate(agents.md) and stores agentsMdPromptId", async () => { + setupCopilotAgent(); + (mocks.taskPromptStore as any).findOrCreate.mockResolvedValue({ + _id: "agents-xyz", + type: "agents.md", + text: "# AGENTS\nBe terse.", + createdAt: new Date(), + }); + + const res = await request(app) + .post("/api/v1/requests?worker=coder-acp-copilot") + .send({ + scenario: { task: "Build something", criteria: ["works"] }, + model: "claude-haiku-4.5", + agentsMd: "# AGENTS\nBe terse.", + }); + + expect(res.status).toBe(201); + const foc = (mocks.taskPromptStore.findOrCreate as any).mock.calls.find( + (c: any[]) => c[1] === "agents.md" + ); + expect(foc).toBeDefined(); + expect(foc[0]).toBe("# AGENTS\nBe terse."); + const doc = (mocks.collection.insertOne as any).mock.calls[0][0]; + expect(doc).toHaveProperty("agentsMdPromptId", "agents-xyz"); + }); + + it("persists agentsMdParentIds lineage on the request", async () => { + setupCopilotAgent(); + (mocks.taskPromptStore as any).findOrCreate.mockResolvedValue({ + _id: "agents-child", + type: "agents.md", + text: "child", + createdAt: new Date(), + }); + + const res = await request(app) + .post("/api/v1/requests?worker=coder-acp-copilot") + .send({ + scenario: { task: "Build something", criteria: ["works"] }, + model: "claude-haiku-4.5", + agentsMd: "child", + agentsMdParentIds: ["agents-p1", "agents-p2"], + }); + + expect(res.status).toBe(201); + const doc = (mocks.collection.insertOne as any).mock.calls[0][0]; + expect(doc).toHaveProperty("agentsMdParentIds", ["agents-p1", "agents-p2"]); + }); + + it("omits AGENTS.md fields when not provided", async () => { + setupCopilotAgent(); + + const res = await request(app) + .post("/api/v1/requests?worker=coder-acp-copilot") + .send({ + scenario: { task: "Build something", criteria: ["works"] }, + model: "claude-haiku-4.5", + }); + + expect(res.status).toBe(201); + const doc = (mocks.collection.insertOne as any).mock.calls[0][0]; + expect(doc.agentsMdPromptId).toBeUndefined(); + expect(doc.agentsMdParentIds).toBeUndefined(); + }); + }); + // =================================================================== // Submit with gates — free-text gate prompt materialization // =================================================================== diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cb4c13bd6..6729ea8c7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -130,7 +130,10 @@ async function initializeClients(): Promise { mcpServerCollection = db.collection("mcp-servers"); insightsCollection = db.collection("insights"); taskPromptCollection = db.collection("task-prompts"); - taskPromptStore = new TaskPromptStore(taskPromptCollection); + // Blob storage is also (re)assigned below for logs/snapshots; construct an + // instance here so the task-prompt store can offload over-threshold bodies. + blobStorage = new BlobStorage({ storageAccountName, storageConnectionString }); + taskPromptStore = new TaskPromptStore(taskPromptCollection, blobStorage); featureFlagCollection = db.collection("feature-flags"); reportTemplateCollection = db.collection("report-templates"); skillCollection = db.collection("skills"); @@ -220,7 +223,9 @@ async function initializeClients(): Promise { console.log(`Initialized Queue clients for workers: ${Array.from(queueClients.keys()).join(", ")}, report`); - // Initialize blob storage (used for log persistence and snapshots) + // Initialize blob storage (used for log persistence and snapshots). + // Already constructed above for the task-prompt store; reassign to keep the + // original initialization order/comment intact. blobStorage = new BlobStorage({ storageAccountName, storageConnectionString }); // Initialize Redis-backed heartbeat store. Workers write per-run diff --git a/apps/api/src/routes/prompt-features.ts b/apps/api/src/routes/prompt-features.ts index 6a462d23c..d55f556b3 100644 --- a/apps/api/src/routes/prompt-features.ts +++ b/apps/api/src/routes/prompt-features.ts @@ -110,6 +110,7 @@ apiRoute(ctx.app, ctx.registry, { $setOnInsert: { id: trimmedId, prompt: config.prompt.trim(), + ...(config.type ? { type: config.type } : {}), createdAt: new Date(), }, }, @@ -134,6 +135,7 @@ apiRoute(ctx.app, ctx.registry, { body: z.object({ text: z.string(), model: z.string().optional(), + type: z.enum(["select", "agents.md"]).optional(), }), response: z.object({ features: z.array(PromptFeatureResultSchema), @@ -145,7 +147,7 @@ apiRoute(ctx.app, ctx.registry, { 503: { description: "LLM not configured" }, }, handler: async (req, res, next) => { - const { text, model } = req.body; + const { text, model, type } = req.body; if (!text || typeof text !== "string" || !text.trim()) { res.status(400).json({ error: "Body must contain a non-empty 'text' string" }); return; @@ -156,8 +158,15 @@ apiRoute(ctx.app, ctx.registry, { return; } + // Scope candidate features to the requested type (default = select, + // including legacy features with no `type`). + const promptType = type ?? "select"; + const typeFilter: Record = + promptType === "select" + ? { $or: [{ type: "select" }, { type: { $exists: false } }] } + : { type: promptType }; const allFeatures = await ctx.promptFeatureCollection - .find({ deletedAt: { $exists: false } }) + .find({ deletedAt: { $exists: false }, ...typeFilter }) .toArray(); const featureConfigs = allFeatures.map(f => ({ id: f.id, prompt: f.prompt })); @@ -185,16 +194,30 @@ apiRoute(ctx.app, ctx.registry, { path: "/api/v1/prompt-features", tags: ["Prompt Features"], summary: "List features", - query: z.object({ q: z.string().optional() }), + query: z.object({ q: z.string().optional(), type: z.enum(["select", "agents.md"]).optional() }), response: z.array(PromptFeatureResponseSchema), handler: async (req, res) => { const q = req.query.q; + const type = req.query.type; const filter: Record = { deletedAt: { $exists: false } }; + if (type) { + // Absent `type` is treated as 'select' for backward compatibility. + filter.$and = [ + type === "select" + ? { $or: [{ type: "select" }, { type: { $exists: false } }] } + : { type }, + ]; + } if (q) { - filter.$or = [ + const search = [ { id: { $regex: q, $options: "i" } }, { prompt: { $regex: q, $options: "i" } }, ]; + if (filter.$and) { + (filter.$and as unknown[]).push({ $or: search }); + } else { + filter.$or = search; + } } const features = await ctx.promptFeatureCollection.find(filter).toArray(); features.sort((a, b) => a.id.localeCompare(b.id)); @@ -239,7 +262,7 @@ apiRoute(ctx.app, ctx.registry, { 409: { description: "Feature already exists" }, }, handler: async (req, res) => { - const { id, prompt } = req.body; + const { id, prompt, type } = req.body; if (!id || typeof id !== "string") { res.status(400).json({ error: "id is required and must be a string" }); @@ -263,6 +286,7 @@ apiRoute(ctx.app, ctx.registry, { const doc: PromptFeatureDocument = { id, prompt: prompt.trim(), + ...(type ? { type } : {}), createdAt: new Date(), }; diff --git a/apps/api/src/routes/requests/index.ts b/apps/api/src/routes/requests/index.ts index b6fcd4b1d..d6be12ac0 100644 --- a/apps/api/src/routes/requests/index.ts +++ b/apps/api/src/routes/requests/index.ts @@ -168,9 +168,33 @@ apiRoute(ctx.app, ctx.registry, { response: z.union([RequestResponseSchema, z.array(RequestResponseSchema)]), successStatus: 201, handler: async (req, res) => { - const { scenario: scenarioObj, persona: personaObj, maxIterations, personaInstructions, count = 1, promptFeatureExtractionId, model: requestedModel, reasoningEffort: requestedReasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, extensions: extensionIds, agentVersion: requestedAgentVersion, profileId: requestedProfileSpec, profileVariations, priority: requestedPriority, gates: requestedGates, codebase: codebaseSpec, codebaseRevisionId: requestedCodebaseRevisionId } = req.body; + const { scenario: scenarioObj, persona: personaObj, maxIterations, personaInstructions, count = 1, promptFeatureExtractionId, model: requestedModel, reasoningEffort: requestedReasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, extensions: extensionIds, agentVersion: requestedAgentVersion, profileId: requestedProfileSpec, profileVariations, priority: requestedPriority, agentsMd: requestedAgentsMd, agentsMdParentIds: requestedAgentsMdParentIds, gates: requestedGates, codebase: codebaseSpec, codebaseRevisionId: requestedCodebaseRevisionId } = req.body; let worker = req.query.worker as string | undefined; + // AGENTS.md body + lineage (for any caller that wants to attach an + // AGENTS.md instruction file to a run). + const agentsMdText = + typeof requestedAgentsMd === "string" && requestedAgentsMd.trim() + ? requestedAgentsMd + : undefined; + const agentsMdParentIds = Array.isArray(requestedAgentsMdParentIds) + ? requestedAgentsMdParentIds.filter((x: unknown): x is string => typeof x === "string") + : undefined; + + // Resolve the AGENTS.md body to an `agents.md`-typed prompt id (idempotent, + // content-addressed; large bodies are offloaded to blob by the store). + const resolveAgentsMdPromptId = async (): Promise => { + if (!agentsMdText) return undefined; + const p = await ctx.taskPromptStore.findOrCreate(agentsMdText, "agents.md"); + return p._id; + }; + + // Common request fields for AGENTS.md delivery + lineage. + const buildAgentsMdFields = (agentsMdPromptId?: string) => ({ + ...(agentsMdPromptId ? { agentsMdPromptId } : {}), + ...(agentsMdParentIds && agentsMdParentIds.length ? { agentsMdParentIds } : {}), + }); + // Resolve the optional per-run codebase selection into a concrete revision id. // Accepts an already-resolved `codebaseRevisionId`, or a `codebase` spec // (revision id / `{slug}@r{N}` ref / bare slug). A bare git slug resolves the @@ -463,6 +487,8 @@ apiRoute(ctx.app, ctx.registry, { const allNewIds: string[] = []; const newDocs: RequestDocument[] = []; const variationResults: Array<{ profileId: string; label?: string; ids: string[] }> = []; + const agentsMdPromptId = await resolveAgentsMdPromptId(); + const agentsMdFields = buildAgentsMdFields(agentsMdPromptId); for (const r of resolved) { const newIds: string[] = []; @@ -495,6 +521,7 @@ apiRoute(ctx.app, ctx.registry, { profileId: r.profile._id, profileVersionId: r.profileVersion._id, submissionId, + ...agentsMdFields, run: { _id: runId, attemptNumber: 1, status: "pending", logsUrl: ctx.blobStorage.getLogsBlobUrl(`${requestId}/runs/${runId}/run.jsonl`) }, }; newDocs.push(requestDoc); @@ -879,6 +906,10 @@ apiRoute(ctx.app, ctx.registry, { const taskPrompt = await ctx.taskPromptStore.findOrCreate(scenario.task); const taskPromptId = taskPrompt._id; + // Resolve AGENTS.md lineage once for this submission (shared across count>1). + const agentsMdPromptId = await resolveAgentsMdPromptId(); + const agentsMdFields = buildAgentsMdFields(agentsMdPromptId); + // Build the persisted gate configs: order canonically and point the Select // gate's prompt at the resolved task prompt (docs/design/gates.md §4.3). const persistedGates: GateConfig[] | undefined = gatesProvided @@ -922,6 +953,7 @@ apiRoute(ctx.app, ctx.registry, { ...(profileVersionId ? { profileVersionId } : {}), ...(persistedGates ? { gates: persistedGates } : {}), submissionId, + ...agentsMdFields, // Mint a distinct run id for the first attempt. Blob artifacts // are scoped under `{requestId}/runs/{runId}/...` so retries // never overwrite a previous attempt's blobs. @@ -982,6 +1014,7 @@ apiRoute(ctx.app, ctx.registry, { ...(profileVersionId ? { profileVersionId } : {}), ...(persistedGates ? { gates: persistedGates } : {}), submissionId, + ...agentsMdFields, // Mint a distinct run id for the first attempt. Blob artifacts // are scoped under `{requestId}/runs/{runId}/...` so retries // never overwrite a previous attempt's blobs. @@ -1619,6 +1652,9 @@ apiRoute(ctx.app, ctx.registry, { ...(original.taskPromptId ? { taskPromptId: original.taskPromptId } : {}), ...(effectiveProfileId ? { profileId: effectiveProfileId } : {}), ...(effectiveProfileVersionId ? { profileVersionId: effectiveProfileVersionId } : {}), + // Preserve AGENTS.md lineage across re-submits. + ...(original.agentsMdPromptId ? { agentsMdPromptId: original.agentsMdPromptId } : {}), + ...(original.agentsMdParentIds && original.agentsMdParentIds.length > 0 ? { agentsMdParentIds: original.agentsMdParentIds } : {}), submissionId, // Bulk re-submit creates a brand-new request — mint a distinct // run id for the first attempt so blob artifacts live under diff --git a/apps/api/src/routes/task-prompts.ts b/apps/api/src/routes/task-prompts.ts index 9cc4faa76..45280e437 100644 --- a/apps/api/src/routes/task-prompts.ts +++ b/apps/api/src/routes/task-prompts.ts @@ -47,7 +47,10 @@ apiRoute(ctx.app, ctx.registry, { // Fetch recent task prompts as context (avoid duplicates) const recentPrompts = await ctx.taskPromptCollection - .find({ deletedAt: { $exists: false } }) + .find({ + deletedAt: { $exists: false }, + $or: [{ type: "select" }, { type: { $exists: false } }], + }) .sort({ createdAt: -1 }) .limit(20) .project({ text: 1, _id: 0 }) @@ -117,7 +120,33 @@ apiRoute(ctx.app, ctx.registry, { }, }); -// POST /api/v1/task-prompts — create (or find existing) task prompt. Idempotent. +// GET /api/v1/task-prompts/:id/content — get the resolved plain-text body +// (inline or downloaded from blob). Used by workers to fetch AGENTS.md / task text. +apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/task-prompts/:id/content", + tags: ["Task Prompts"], + summary: "Get resolved task prompt content", + params: z.object({ id: z.string() }), + response: z.object({ id: z.string(), text: z.string() }), + errorResponses: { + 404: { description: "Task prompt not found" }, + }, + handler: async (req, res, next) => { + try { + const { id } = req.params; + const taskPrompt = await ctx.taskPromptStore.get(id); + if (!taskPrompt) { + res.status(404).json({ error: "Task prompt not found" }); + return; + } + const text = await ctx.taskPromptStore.resolvePromptText(taskPrompt); + res.json({ id, text }); + } catch (err) { + next(err); + } + }, +}); apiRoute(ctx.app, ctx.registry, { method: "post", path: "/api/v1/task-prompts", @@ -135,8 +164,12 @@ apiRoute(ctx.app, ctx.registry, { return; } - const taskPrompt = await ctx.taskPromptStore.findOrCreate(text, type); - res.status(201).json(taskPrompt); + try { + const taskPrompt = await ctx.taskPromptStore.findOrCreate(text, type); + res.status(201).json(taskPrompt); + } catch (err) { + next(err); + } }, }); @@ -214,13 +247,21 @@ apiRoute(ctx.app, ctx.registry, { return; } + // Scope candidate features to the prompt's own type: task prompts get task + // features; AGENTS.md prompts get agents.md features. Absent type ⇒ select. + const promptType = taskPrompt.type ?? "select"; + const typeFilter: Record = + promptType === "select" + ? { $or: [{ type: "select" }, { type: { $exists: false } }] } + : { type: promptType }; const allFeatures = await ctx.promptFeatureCollection - .find({ deletedAt: { $exists: false } }) + .find({ deletedAt: { $exists: false }, ...typeFilter }) .toArray(); const featureConfigs = allFeatures.map(f => ({ id: f.id, prompt: f.prompt })); try { - const { results, suggestedFeatures } = await extractPromptFeatures(taskPrompt.text, featureConfigs, model); + const promptText = await ctx.taskPromptStore.resolvePromptText(taskPrompt); + const { results, suggestedFeatures } = await extractPromptFeatures(promptText, featureConfigs, model); // Store features on the task prompt entity const updated = await ctx.taskPromptStore.attachFeatures(id, results); diff --git a/apps/api/src/test-helpers.ts b/apps/api/src/test-helpers.ts index 6230d6d02..a35282d41 100644 --- a/apps/api/src/test-helpers.ts +++ b/apps/api/src/test-helpers.ts @@ -119,6 +119,7 @@ export function createMockTaskPromptStore(): Record { createdAt: new Date(), }), getAll: vi.fn().mockResolvedValue({ items: [], total: 0 }), + resolvePromptText: vi.fn().mockResolvedValue("mock task"), delete: vi.fn().mockResolvedValue(undefined), attachFeatures: vi.fn().mockResolvedValue({ _id: "tp-mock-id", From 666dc2c1897345ca2e5910adfbc0b47ef6dc184d Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 25 Jun 2026 12:04:49 -0700 Subject: [PATCH 03/14] feat(worker): deliver AGENTS.md into the workspace before the run starts The shared queue processor resolves the agentsMdPromptId body (downloading from blob storage when needed) and writes /AGENTS.md once before the first turn. Fails the run loudly if the prompt id is set but cannot be resolved, rather than silently running the baseline (#732). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../shared/src/queue/queue-processor.test.ts | 59 ++++++++++++++++++- packages/shared/src/queue/queue-processor.ts | 45 ++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/queue/queue-processor.test.ts b/packages/shared/src/queue/queue-processor.test.ts index 3c6511466..a0c1a8a00 100644 --- a/packages/shared/src/queue/queue-processor.test.ts +++ b/packages/shared/src/queue/queue-processor.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import os from "node:os"; import { CodingAgentQueueProcessor } from "./queue-processor.js"; import type { QueueProcessorConfig, WorkerProcessor, WorkerResult } from "../types/types.js"; @@ -82,6 +82,63 @@ describe("CodingAgentQueueProcessor.getVersionFields", () => { }); }); +// ─── AGENTS.md workspace delivery ──────────────────────────────────────────── +describe("CodingAgentQueueProcessor.writeAgentsMd", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("no-ops when the request has no agentsMdPromptId", async () => { + const qp = new CodingAgentQueueProcessor(testConfig, stubProcessor); + const log = vi.fn().mockResolvedValue(undefined); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await (qp as any).writeAgentsMd({ _id: "r1" }, "/tmp/does-not-matter", log); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("throws when agentsMdPromptId is set but apiBaseUrl is missing", async () => { + // testConfig has no apiBaseUrl. + const qp = new CodingAgentQueueProcessor(testConfig, stubProcessor); + const log = vi.fn().mockResolvedValue(undefined); + + await expect( + (qp as any).writeAgentsMd({ _id: "r1", agentsMdPromptId: "agents-1" }, "/tmp", log), + ).rejects.toThrow(/apiBaseUrl/); + }); + + it("writes the resolved AGENTS.md body into the workspace root", async () => { + const { mkdtemp, readFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const workspace = await mkdtemp(join(tmpdir(), "agents-md-test-")); + + try { + const qp = new CodingAgentQueueProcessor( + { ...testConfig, apiBaseUrl: "https://api.example.com" }, + stubProcessor, + ); + const log = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: "agents-1", text: "# AGENTS\nBe terse." }), + }), + ); + + await (qp as any).writeAgentsMd({ _id: "r1", agentsMdPromptId: "agents-1" }, workspace, log); + + const written = await readFile(join(workspace, "AGENTS.md"), "utf-8"); + expect(written).toBe("# AGENTS\nBe terse."); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); +}); + // ─── Redelivery fail-fast (handleRequest pre-checks) ───────────────────────── describe("CodingAgentQueueProcessor.handleRequest redelivery handling", () => { function makeHarness(runStatus: string, runOverrides: Record = {}) { diff --git a/packages/shared/src/queue/queue-processor.ts b/packages/shared/src/queue/queue-processor.ts index 52f459aee..f0f48686f 100644 --- a/packages/shared/src/queue/queue-processor.ts +++ b/packages/shared/src/queue/queue-processor.ts @@ -32,6 +32,9 @@ import { McpSecretClient } from "../mcp/mcp-secret-client.js"; import { SkillClient } from "../skills/skill-client.js"; import { ExtensionClient } from "../extensions/extension-client.js"; import { extractSkillsToWorkspace } from "../skills/skill-extractor.js"; +import { PromptClient } from "../task-prompts/prompt-client.js"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { CodebaseClient } from "../codebases/codebase-client.js"; import { seedCodebaseToWorkspace } from "../codebases/codebase-seeder.js"; @@ -410,6 +413,44 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor/AGENTS.md` before the first turn. The text is resolved via + * the API (`GET /api/v1/task-prompts/:id/content`), which downloads from blob + * storage when the prompt is blob-backed — the worker never touches blob. + * + * Fails loudly (throws) when `agentsMdPromptId` is set but `apiBaseUrl` is + * missing or the fetch/write fails, so the run is marked failed rather than + * silently evaluating the baseline worker (which would corrupt results). + */ + private async writeAgentsMd( + requestDoc: RequestDocument, + workspacePath: string, + log: (level: LogEvent["level"], msg: string, data?: Record) => Promise, + ): Promise { + const agentsMdPromptId = requestDoc.agentsMdPromptId; + if (!agentsMdPromptId) return; + + const apiBaseUrl = (this.config as QueueProcessorConfig).apiBaseUrl; + if (!apiBaseUrl) { + throw new Error( + `Request has agentsMdPromptId '${agentsMdPromptId}' but no apiBaseUrl is configured; cannot resolve AGENTS.md`, + ); + } + + const promptClient = new PromptClient(apiBaseUrl); + const text = await promptClient.getText(agentsMdPromptId); + const agentsMdPath = join(workspacePath, "AGENTS.md"); + await writeFile(agentsMdPath, text, "utf-8"); + await log("info", `Wrote AGENTS.md to workspace`, { + agentsMdPromptId, + path: agentsMdPath, + bytes: Buffer.byteLength(text, "utf-8"), + }); + } + /** * Resolve a typed prompt entity's text by id via the API. Used for non-Select * gates whose prompt is referenced by id on the request's gate config. Throws @@ -596,6 +637,10 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor Date: Thu, 25 Jun 2026 12:04:58 -0700 Subject: [PATCH 04/14] feat(cli): --agents-md run flag and type-aware task-prompt/prompt-feature commands Add CLI parity for AGENTS.md support (#732): 'run submit --agents-md ' submits a run with an AGENTS.md prompt, and the task-prompt and prompt-feature commands gain a --type option to manage 'agents.md' prompts alongside task prompts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/cli/src/commands/prompt-feature.test.ts | 101 ++++++++++++++++++ apps/cli/src/commands/prompt-feature.ts | 8 +- apps/cli/src/commands/run.test.ts | 59 +++++++++++ apps/cli/src/commands/run.ts | 23 +++- apps/cli/src/commands/task-prompt.test.ts | 106 +++++++++++++++++++ apps/cli/src/commands/task-prompt.ts | 20 ++-- apps/cli/src/utils/gates.ts | 6 +- 7 files changed, 312 insertions(+), 11 deletions(-) create mode 100644 apps/cli/src/commands/prompt-feature.test.ts create mode 100644 apps/cli/src/commands/task-prompt.test.ts diff --git a/apps/cli/src/commands/prompt-feature.test.ts b/apps/cli/src/commands/prompt-feature.test.ts new file mode 100644 index 000000000..a29e695de --- /dev/null +++ b/apps/cli/src/commands/prompt-feature.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { registerPromptFeatureCommands } from "./prompt-feature.js"; + +function makeProgram(): Command { + const program = new Command(); + registerPromptFeatureCommands(program); + return program; +} + +function mockFetchWith(body: unknown): void { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => body })); +} + +function fetchMock(): ReturnType { + return fetch as unknown as ReturnType; +} + +describe("prompt-feature list", () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it("passes --type as a query param", async () => { + mockFetchWith([]); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const program = makeProgram(); + await program.parseAsync( + ["prompt-feature", "list", "--type", "agents.md", "-u", "http://localhost:3100"], + { from: "user" }, + ); + } finally { + logSpy.mockRestore(); + } + + const url = fetchMock().mock.calls[0][0] as string; + expect(url).toContain("type=agents.md"); + }); + + it("renders a Type column defaulting to 'select'", async () => { + mockFetchWith([{ id: "feat-1", prompt: "detect something" }]); + const lines: string[] = []; + const logSpy = vi.spyOn(console, "log").mockImplementation((...p: unknown[]) => { + lines.push(p.map(String).join(" ")); + }); + + try { + const program = makeProgram(); + await program.parseAsync(["prompt-feature", "list", "-u", "http://localhost:3100"], { from: "user" }); + } finally { + logSpy.mockRestore(); + } + + expect(lines.join("\n")).toContain("select"); + }); +}); + +describe("prompt-feature create", () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it("sends type in the create body when provided", async () => { + mockFetchWith({ id: "feat-new" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const program = makeProgram(); + await program.parseAsync( + ["prompt-feature", "create", "--id", "my_feat", "--prompt", "detect x", "--type", "agents.md", "-u", "http://localhost:3100"], + { from: "user" }, + ); + } finally { + logSpy.mockRestore(); + } + + const init = fetchMock().mock.calls[0][1] as { body: string }; + expect(JSON.parse(init.body)).toEqual({ id: "my_feat", prompt: "detect x", type: "agents.md" }); + }); + + it("omits type when not provided (backward compatible)", async () => { + mockFetchWith({ id: "feat-new-2" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const program = makeProgram(); + await program.parseAsync( + ["prompt-feature", "create", "--id", "my_feat2", "--prompt", "detect y", "-u", "http://localhost:3100"], + { from: "user" }, + ); + } finally { + logSpy.mockRestore(); + } + + const init = fetchMock().mock.calls[0][1] as { body: string }; + expect(JSON.parse(init.body)).toEqual({ id: "my_feat2", prompt: "detect y" }); + }); +}); diff --git a/apps/cli/src/commands/prompt-feature.ts b/apps/cli/src/commands/prompt-feature.ts index 49dd6c7d5..e17d7a39e 100644 --- a/apps/cli/src/commands/prompt-feature.ts +++ b/apps/cli/src/commands/prompt-feature.ts @@ -29,6 +29,7 @@ promptFeature .command("list") .description("List all prompt features") .option("-q, --query ", "Filter by ID or prompt text") + .option("--type ", "Filter by feature type ('select' or 'agents.md')") .option("-u, --url ", "API base URL", getDefaultApiUrl()) ) .action(async (options) => { @@ -36,6 +37,7 @@ promptFeature try { const params = new URLSearchParams(); if (options.query) params.set("q", options.query); + if (options.type) params.set("type", options.type); const qs = params.toString(); const response = await fetch(`${normalizeUrl(options.url)}/api/v1/prompt-features${qs ? `?${qs}` : ""}`); @@ -45,7 +47,7 @@ promptFeature process.exit(1); } - const items = await response.json() as Array<{ id: string; prompt: string }>; + const items = await response.json() as Array<{ id: string; prompt: string; type?: string }>; if (items.length === 0) { if (!isMachineReadable(format)) console.log(warnBanner("No prompt features found.")); return; @@ -59,6 +61,8 @@ promptFeature { key: 'id', label: 'ID', tableFormatter: (f: any) => value(f.id), }, + { key: 'type', label: 'Type', formatter: (f: any) => f.type ?? 'select', + tableFormatter: (f: any) => value(f.type ?? 'select') }, { key: 'prompt', label: 'Prompt', formatter: (f: any) => { const prompt = f.prompt.replace(/\n/g, ' '); return prompt.length > 60 ? prompt.substring(0, 60) + '…' : prompt; @@ -128,6 +132,7 @@ promptFeature .description("Create a new prompt feature") .requiredOption("--id ", "Prompt feature ID (lowercase snake_case)") .requiredOption("--prompt ", "Detection prompt for the feature") + .option("--type ", "Feature type ('select' default, or 'agents.md')") .option("-u, --url ", "API base URL", getDefaultApiUrl()) .action(async (options) => { try { @@ -135,6 +140,7 @@ promptFeature id: options.id, prompt: options.prompt, }; + if (options.type) body.type = options.type; const response = await fetch(`${normalizeUrl(options.url)}/api/v1/prompt-features`, { method: "POST", diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index ba2ea253e..aa2af017b 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -157,6 +157,65 @@ describe("run list", () => { }); }); +describe("run submit", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function captureSubmit(args: string[]): { url: string; body: Record } { + const fetchMock = fetch as unknown as ReturnType; + const [url, init] = fetchMock.mock.calls[0]; + return { url: url as string, body: JSON.parse((init as { body: string }).body) }; + } + + it("includes agentsMd in the submit body", async () => { + mockFetchWith({ id: "req-submit-1", workerType: "coder-acp-copilot", status: "queued" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const program = makeProgram(); + await program.parseAsync( + [ + "run", "submit", + "-m", "do the thing", + "--agents-md", "# Be helpful", + "--no-stream", + "-u", "http://localhost:3100", + ], + { from: "user" }, + ); + } finally { + logSpy.mockRestore(); + } + + const { url, body } = captureSubmit([]); + expect(url).toContain("/api/v1/requests?worker=coder-acp-copilot"); + expect(body.agentsMd).toBe("# Be helpful"); + }); + + it("omits agentsMd when not provided", async () => { + mockFetchWith({ id: "req-submit-2", workerType: "coder-acp-copilot", status: "queued" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const program = makeProgram(); + await program.parseAsync( + ["run", "submit", "-m", "plain task", "--no-stream", "-u", "http://localhost:3100"], + { from: "user" }, + ); + } finally { + logSpy.mockRestore(); + } + + const { body } = captureSubmit([]); + expect(body).not.toHaveProperty("agentsMd"); + }); +}); + describe("run retry", () => { beforeEach(() => { vi.restoreAllMocks(); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 7da9840ea..a01d428b3 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -18,6 +18,18 @@ import { runGetAction } from "../run-get-action.js"; import { normalizeUrl, printFollowUpCommands, withOutputOption, getDefaultApiUrl } from "../utils/shared.js"; import { parseGatesOption } from "../utils/gates.js"; +/** + * Resolve a CLI option that may be either a literal string or a `@path` + * reference to a file whose contents should be read. Used for flags like + * `--agents-md` where large bodies are inconvenient to pass inline. + */ +function resolveTextOrFile(input: string): string { + if (input.startsWith("@")) { + return readFileSync(resolve(input.slice(1)), "utf8"); + } + return input; +} + export function registerRunCommands(program: Command): void { const run = program .command("run") @@ -48,11 +60,12 @@ run .option("--profile ", "Saved profile to apply (supplies worker, model, extensions, etc.)") .addOption(new Option("--base-profile ", "Deprecated alias for --profile.").hideHelp()) .option("--profile-variations-file ", "Path to JSON file containing profile variation entries") + .option("--agents-md ", "AGENTS.md content delivered to the workspace (prefix with @ to read from a file)") .option("--gates ", "GateConfig[] JSON or path/@path to a JSON file for gated runs") .option("-u, --url ", "API base URL", process.env.SCOPE_API_URL || "http://localhost:3100") .option("--no-stream", "Don't stream logs, just submit") .action(async (options, command) => { - const { scenario, persona, traits, worker, url, stream, maxIterations, model, reasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, codebase: codebaseRef, extensions: extensionIds, agentVersion, profile, baseProfile, profileVariationsFile, gates: gatesOption } = options; + const { scenario, persona, traits, worker, url, stream, maxIterations, model, reasoningEffort, mcpServers: mcpServerSlugs, skills: skillSlugs, codebase: codebaseRef, extensions: extensionIds, agentVersion, profile, baseProfile, profileVariationsFile, gates: gatesOption, agentsMd: agentsMdInput } = options; // `--profile` is the documented flag; `--base-profile` is kept as a hidden // back-compat alias. Both resolve to the same request `profileId`. const profileId = profile ?? baseProfile; @@ -124,6 +137,14 @@ run if (profileId) { body.profileId = profileId; } + if (agentsMdInput) { + // `@path` reads the AGENTS.md body from a file; otherwise the value is + // treated as the literal content. + const agentsMd = resolveTextOrFile(agentsMdInput); + if (agentsMd.trim().length > 0) { + body.agentsMd = agentsMd; + } + } if (gatesOption) { body.gates = parseGatesOption(gatesOption, maxIterations); } diff --git a/apps/cli/src/commands/task-prompt.test.ts b/apps/cli/src/commands/task-prompt.test.ts new file mode 100644 index 000000000..9265d0909 --- /dev/null +++ b/apps/cli/src/commands/task-prompt.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Command } from "commander"; +import { registerTaskPromptCommands } from "./task-prompt.js"; + +function makeProgram(): Command { + const program = new Command(); + registerTaskPromptCommands(program); + return program; +} + +function mockFetchWith(body: unknown): void { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => body })); +} + +function fetchMock(): ReturnType { + return fetch as unknown as ReturnType; +} + +describe("task-prompt list", () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it("passes --type as a query param", async () => { + mockFetchWith({ items: [], total: 0 }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const program = makeProgram(); + await program.parseAsync( + ["task-prompt", "list", "--type", "agents.md", "-u", "http://localhost:3100"], + { from: "user" }, + ); + } finally { + logSpy.mockRestore(); + } + + const url = fetchMock().mock.calls[0][0] as string; + expect(url).toContain("type=agents.md"); + }); + + it("renders blob-backed prompts without crashing on missing text", async () => { + mockFetchWith({ + items: [{ _id: "abcdef1234", type: "agents.md", contentBlobUrl: "prompts/abcdef1234.txt", createdAt: new Date().toISOString() }], + total: 1, + }); + const lines: string[] = []; + const logSpy = vi.spyOn(console, "log").mockImplementation((...p: unknown[]) => { + lines.push(p.map(String).join(" ")); + }); + + try { + const program = makeProgram(); + await program.parseAsync(["task-prompt", "list", "-u", "http://localhost:3100"], { from: "user" }); + } finally { + logSpy.mockRestore(); + } + + const output = lines.join("\n"); + expect(output).toContain("agents.md"); + expect(output).toContain("(blob)"); + }); +}); + +describe("task-prompt create", () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it("sends type in the create body when provided", async () => { + mockFetchWith({ _id: "id-1", type: "agents.md", createdAt: new Date().toISOString() }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const program = makeProgram(); + await program.parseAsync( + ["task-prompt", "create", "-t", "# Guidance", "--type", "agents.md", "-u", "http://localhost:3100"], + { from: "user" }, + ); + } finally { + logSpy.mockRestore(); + } + + const init = fetchMock().mock.calls[0][1] as { body: string }; + expect(JSON.parse(init.body)).toEqual({ text: "# Guidance", type: "agents.md" }); + }); + + it("defaults type to select when not provided", async () => { + mockFetchWith({ _id: "id-2", createdAt: new Date().toISOString() }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + const program = makeProgram(); + await program.parseAsync( + ["task-prompt", "create", "-t", "plain task", "-u", "http://localhost:3100"], + { from: "user" }, + ); + } finally { + logSpy.mockRestore(); + } + + const init = fetchMock().mock.calls[0][1] as { body: string }; + expect(JSON.parse(init.body)).toEqual({ text: "plain task", type: "select" }); + }); +}); diff --git a/apps/cli/src/commands/task-prompt.ts b/apps/cli/src/commands/task-prompt.ts index 5bb3643a6..74e77909a 100644 --- a/apps/cli/src/commands/task-prompt.ts +++ b/apps/cli/src/commands/task-prompt.ts @@ -29,7 +29,7 @@ taskPrompt .command("list") .description("List all task prompts") .option("-s, --search ", "Filter by text content") - .option("--type ", "Filter by prompt type/gate (select, build, test, run, deploy)") + .option("--type ", "Filter by prompt type/gate (select, build, test, run, deploy, agents.md)") .option("-l, --limit ", "Maximum number of results", "50") .option("--offset ", "Number of results to skip", "0") .option("-u, --url ", "API base URL", getDefaultApiUrl()) @@ -69,10 +69,12 @@ taskPrompt }, { key: 'type', label: 'Type', formatter: (tp: any) => tp.type ?? 'select' }, { key: 'text', label: 'Text', formatter: (tp: any) => { - const text = tp.text.replace(/\n/g, ' '); + const text = (tp.text ?? '').replace(/\n/g, ' '); + if (!text) return tp.contentBlobUrl ? '(blob)' : ''; return text.length > 60 ? text.substring(0, 60) + '…' : text; }, tableFormatter: (tp: any) => { - const text = tp.text.replace(/\n/g, ' '); + const text = (tp.text ?? '').replace(/\n/g, ' '); + if (!text) return dimTimestamp(tp.contentBlobUrl ? '(blob)' : ''); const truncated = text.length > 60 ? text.substring(0, 60) + '…' : text; return dimTimestamp(truncated); }}, @@ -110,7 +112,7 @@ taskPrompt } const tp = await response.json() as { - _id: string; text: string; type?: PromptType; + _id: string; text?: string; type?: PromptType; contentBlobUrl?: string; features?: Array<{ featureId: string; detected: boolean; evaluated: boolean }>; featuresExtractedAt?: string; createdAt: string; deletedAt?: string; @@ -139,8 +141,12 @@ taskPrompt console.log(`${label('Created:')} ${value(tp.createdAt)}`); if (tp.deletedAt) console.log(`${label('Deleted:')} ${value(tp.deletedAt)}`); console.log(`${label('Text:')}`); - for (const line of tp.text.trim().split('\n')) { - console.log(` ${line}`); + if (tp.text) { + for (const line of tp.text.trim().split('\n')) { + console.log(` ${line}`); + } + } else { + console.log(` ${dimTimestamp('(stored in blob — fetch via /api/v1/task-prompts/:id/content)')}`); } if (tp.features && tp.features.length > 0) { @@ -210,7 +216,7 @@ taskPrompt process.exit(1); } - const tp = await response.json() as { _id: string; text: string; type?: PromptType; createdAt: string }; + const tp = await response.json() as { _id: string; text?: string; type?: PromptType; createdAt: string }; console.log(successText(`Task prompt registered.`)); console.log(`${label('ID:')} ${value(tp._id)}`); console.log(`${label('Type:')} ${value(tp.type ?? type)}`); diff --git a/apps/cli/src/utils/gates.ts b/apps/cli/src/utils/gates.ts index d368aa993..6f914bc26 100644 --- a/apps/cli/src/utils/gates.ts +++ b/apps/cli/src/utils/gates.ts @@ -6,7 +6,9 @@ import { resolve } from "path"; import { GATES, isGateId, + isPromptType, orderGates, + PROMPT_TYPES, validateGateConfigs, type GateConfig, type GateId, @@ -63,8 +65,8 @@ export function parseGateListOption(raw: string | string[] | undefined): GateId[ export function parsePromptTypeOption(raw: string | undefined): PromptType | undefined { if (raw === undefined) return undefined; const trimmed = raw.trim(); - if (!isGateId(trimmed)) { - throw new Error(`Invalid prompt type '${raw}'. Valid types: ${GATES.join(", ")}.`); + if (!isPromptType(trimmed)) { + throw new Error(`Invalid prompt type '${raw}'. Valid types: ${PROMPT_TYPES.join(", ")}.`); } return trimmed; } From 181bc385e39d015188a9930110b35849b21c8d4b Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Thu, 25 Jun 2026 12:05:11 -0700 Subject: [PATCH 05/14] feat(portal): submit AGENTS.md with a run and manage agents.md prompts Add portal surfaces for AGENTS.md support (#732): an AGENTS.md field on the Submit Run page, AGENTS.md display on Run Detail, and type-aware task-prompt and prompt-feature views/pickers for 'agents.md' prompts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/PromptFeatureWizard.tsx | 24 ++++++++++- .../src/components/TaskPromptIdPicker.tsx | 4 +- .../src/components/TaskPromptPicker.tsx | 8 ++-- apps/portal/src/lib/api.ts | 14 +++++-- apps/portal/src/lib/gates.ts | 11 +++++ apps/portal/src/pages/PromptFeatureList.tsx | 41 ++++++++++++++++--- apps/portal/src/pages/RunDetail.tsx | 39 ++++++++++++++++++ apps/portal/src/pages/SubmitRun.tsx | 18 ++++++++ apps/portal/src/pages/TaskPromptDetail.tsx | 4 +- apps/portal/src/pages/TaskPromptList.tsx | 6 +-- .../src/pages/TaskPromptPreviewPanel.tsx | 6 ++- apps/portal/src/types.ts | 13 ++++-- 12 files changed, 162 insertions(+), 26 deletions(-) diff --git a/apps/portal/src/components/PromptFeatureWizard.tsx b/apps/portal/src/components/PromptFeatureWizard.tsx index fde815683..46a90db34 100644 --- a/apps/portal/src/components/PromptFeatureWizard.tsx +++ b/apps/portal/src/components/PromptFeatureWizard.tsx @@ -10,8 +10,9 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Stepper } from "@/components/Stepper"; -import type { PromptFeatureDocument } from "@/types"; +import type { PromptFeatureDocument, PromptType } from "@/types"; import { ArrowLeft, ArrowRight, @@ -35,6 +36,8 @@ export interface PromptFeatureWizardProps { initialId?: string; /** Pre-fill detection prompt (skips to step 2 when provided) */ initialPrompt?: string; + /** Pre-select the prompt type this feature applies to (default "select") */ + initialType?: PromptType; /** Called after successful feature creation */ onCreated?: (feature: PromptFeatureDocument) => void; /** Called when user clicks Cancel (step 1) or Back at step 1 */ @@ -45,6 +48,7 @@ export function PromptFeatureWizard({ initialBehavior = "", initialId = "", initialPrompt = "", + initialType = "select", onCreated, onCancel, }: PromptFeatureWizardProps) { @@ -58,6 +62,7 @@ export function PromptFeatureWizard({ const [id, setId] = useState(initialId); const [idManuallyEdited, setIdManuallyEdited] = useState(!!initialId); const [idEditMode, setIdEditMode] = useState(false); + const [type, setType] = useState(initialType); // Step 2 fields const [prompt, setPrompt] = useState(initialPrompt); @@ -117,6 +122,7 @@ export function PromptFeatureWizard({ createMutation.mutate({ id: id.trim(), prompt: prompt.trim(), + ...(type !== "select" ? { type } : {}), }); }; @@ -170,6 +176,22 @@ export function PromptFeatureWizard({

+
+ + +

+ Features are only extracted against prompts of their own type. +

+
+
+ {/* ─── Advanced: AGENTS.md ──────────────── */} +
+ +