@@ -74,7 +74,9 @@ export function TaskPromptPreviewPanel() {
- {truncate(taskPrompt.text, 1200)}
+ {taskPrompt.text !== undefined
+ ? truncate(taskPrompt.text, 1200)
+ : "(Stored in blob storage — open the full view to load the body.)"}
diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts
index 74918b4bf..5a5d12142 100644
--- a/apps/portal/src/types.ts
+++ b/apps/portal/src/types.ts
@@ -143,6 +143,10 @@ export interface Run {
submissionId?: string;
profileId?: string;
profileVersionId?: string;
+ /** Resolved AGENTS.md prompt id when an AGENTS.md was supplied. */
+ agentsMdPromptId?: string;
+ /** Parent AGENTS.md prompt ids forming the AGENTS.md lineage (mutation/merge edges). */
+ agentsMdParentIds?: string[];
gates?: GateConfig[];
gateSummaries?: GateRunSummary[];
}
@@ -158,7 +162,7 @@ export interface CursorPaginatedResponse {
}
export type GateId = "select" | "build" | "test" | "run" | "deploy";
-export type PromptType = GateId;
+export type PromptType = GateId | "agents.md";
export interface GateConfig {
gate: GateId;
@@ -230,6 +234,8 @@ export interface GeneratePromptResponse {
export interface PromptFeatureConfig {
id: string;
prompt: string;
+ /** Prompt type this feature applies to (absent ⇒ "select"). */
+ type?: PromptType;
}
export interface PromptFeatureDocument extends PromptFeatureConfig {
@@ -264,8 +270,9 @@ export interface PromptFeatureExtraction {
// Task Prompt types (first-class entity for benchmark task texts)
export interface TaskPrompt {
_id: string; // UUIDv5 content-addressed ID
- text: string;
- type?: PromptType; // Full task prompt text
+ text?: string; // Full task prompt text (absent when blob-backed)
+ type?: PromptType; // Gate id or "agents.md"; absent ⇒ legacy "select"
+ contentBlobUrl?: string; // Blob reference when body exceeds the inline threshold
features?: PromptFeatureResult[]; // Detected prompt features
featuresExtractedAt?: string; // When features were last extracted
createdAt: string;
diff --git a/docs/architecture/app-design.md b/docs/architecture/app-design.md
index a5babb604..f2e92e8fc 100644
--- a/docs/architecture/app-design.md
+++ b/docs/architecture/app-design.md
@@ -53,6 +53,43 @@ erDiagram
- **Codebase** — Mutable first-class project entity in `codebases`, with a unique slug, source type (`git` or `archive`), optional GitHub source/default branch, revision counter, latest revision pointer, and soft-delete metadata.
- **CodebaseRevision** — Immutable snapshot in `codebase-revisions`. Every Git resolution or archive upload creates a fresh UUID revision with the next per-codebase `revisionNumber` and canonical `{slug}@r{N}` ref.
+### Typed prompts, AGENTS.md, and size-based storage
+
+Task prompts live in a single `task-prompts` collection that is now **typed** and
+shared by two prompt kinds:
+
+- `TaskPromptDocument.type?: 'task' | 'agents.md'` — absent ⇒ `'task'` (backward
+ compatible; existing docs are untouched).
+- **`_id` is the content hash.** `computeTaskPromptId(text, type?)` hashes the
+ trimmed text for `task` (or absent) — identical to the legacy hash, so every
+ existing task prompt keeps its `_id` — and namespaces non-task types as
+ `hash(type + '\n' + text)` so an AGENTS.md prompt never collides with a task
+ prompt of the same text. `findOrCreate` deduplicates on this hash.
+- **Body storage is decided by size, not type.** A prompt body at/under
+ `PROMPT_INLINE_MAX_BYTES` (default 16 KB, UTF-8) is stored inline as `text`;
+ larger bodies are uploaded to blob (`prompts/{promptId}.txt`) and the doc carries
+ `contentBlobUrl` with no inline `text`. Exactly one of `text` / `contentBlobUrl`
+ is set. `resolvePromptText(doc)` returns the inline body or downloads the blob, so
+ callers (feature extraction, the worker text endpoint) get plain text regardless
+ of location.
+- **Prompt features are typed the same way.** `PromptFeatureDocument.type?:
+ 'task' | 'agents.md'` (absent ⇒ `'task'`); feature extraction selects only
+ features of the prompt's type.
+
+### AGENTS.md delivery
+
+To support submitting an AGENTS.md prompt with a run, the request carries:
+
+- `RequestDocument.agentsMdPromptId?: string` — set when the create-request body
+ includes `agentsMd` (raw text); the API `findOrCreate`s an `agents.md`-typed
+ prompt and stores its id. Before the run starts, the shared queue-processor
+ resolves the text (downloading from blob if needed) and writes
+ `/AGENTS.md` once (constant for the whole run). It **fails the run**
+ if the prompt id is set but cannot be resolved — never silently runs the baseline.
+- `RequestDocument.agentsMdParentIds?: string[]` — best-effort lineage edges
+ (`[]`/absent = root, `[p]` = mutation, `[i, j]` = merge) for callers that know
+ parentage at submit time.
+
## Judge Pipeline
The judge evaluates coding agent output against criteria. Two strategies are supported:
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/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 {
+ 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..22d19b0f1 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 all prompt types (including agents.md 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(2);
+ expect(items.map((i) => i.text).sort()).toEqual(["a task prompt", "an agents file"]);
+ });
+
+ 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..da3f9e682 100644
--- a/packages/shared/src/task-prompts/task-prompt-store.ts
+++ b/packages/shared/src/task-prompts/task-prompt-store.ts
@@ -4,23 +4,50 @@
import { Collection } from 'mongodb';
import { TaskPromptDocument, PromptFeatureResult, PromptType } 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,9 @@ 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. Pass a `type` (e.g. `'select'` or `'agents.md'`) to scope the
+ * list to a single type. With no `type`, all prompt types are returned — gate
+ * prompts, non-gate types such as `agents.md`, and legacy untyped docs.
*/
async getAll(opts?: {
limit?: number;
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) */