Fix curator prompt leakage

This commit is contained in:
Sai-karthik
2026-06-15 10:38:33 +00:00
parent 37fa8f13f4
commit 3fecfdc403
2 changed files with 105 additions and 62 deletions

View File

@@ -39,7 +39,19 @@ function buildId(prefix: string) {
}
function sanitize(text: string) {
return text
const withoutControlLines = text
.split(/\r?\n/)
.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
if (/^(date|curator task id|focused subtask|curator task title|curator task context|curator task subtasks|curator service|expected completion events|captured task memory|task title|task service|task context|all task subtasks|visible history):/i.test(trimmed)) return false;
if (/^```/.test(trimmed)) return false;
return true;
})
.join("\n")
.trim();
const withoutJsonEnvelope = withoutControlLines.replace(/^\s*\{[\s\S]*"reply"\s*:\s*"([^"]+)"[\s\S]*\}\s*$/i, "$1");
return withoutJsonEnvelope
.replace(/[\u2013\u2014]/g, "-")
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
@@ -48,6 +60,19 @@ function sanitize(text: string) {
.trim();
}
function pushField(lines: string[], label: string, value?: string | number | null) {
if (value === undefined || value === null) return;
const stringValue = String(value).trim();
if (!stringValue) return;
lines.push(`${label}: ${stringValue}`);
}
function pushList(lines: string[], label: string, values?: string[]) {
const cleanValues = values?.map((value) => value.trim()).filter(Boolean) ?? [];
if (cleanValues.length === 0) return;
lines.push(`${label}: ${cleanValues.join(" | ")}`);
}
type CuratorMessage = Awaited<ReturnType<typeof listMessagesPg>>[number];
async function capturedSubtaskMemory(userId: string, taskId?: string) {
@@ -131,7 +156,7 @@ function curatorSystemAddendum(input: {
task?: Awaited<ReturnType<typeof buildCuratorTasks>>[number];
taskMemory?: Array<{ subtaskIndex?: number; subtask?: string; summary?: string }>;
}) {
return [
const lines = [
"You are currently speaking as the GrowQR V1 Curator through the Conversation Actor.",
"The V1 Curator owns 30 day direction, streak continuity, and service handoff decisions.",
"Carry state from the conversation history. If the user gives a short answer like a role name, accept it and ask for the next missing slot.",
@@ -144,17 +169,26 @@ function curatorSystemAddendum(input: {
"When the user has answered the focused subtask enough, summarize what was captured and stop. Do not ask the next subtask question.",
"If more detail is needed, ask exactly one follow-up question for the focused subtask only.",
"Use captured task memory from previous subtasks as context. Do not ask the user to repeat details already captured there.",
`Date: ${input.date}`,
`Curator task id: ${input.taskId ?? "none"}`,
`Focused subtask index: ${Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : "none"}`,
`Focused subtask title: ${input.subtask ?? "none"}`,
`Curator task title: ${input.task?.title ?? "General curator chat"}`,
`Curator task context: ${input.task?.contextNarrative ?? "none"}`,
`Curator task subtasks: ${input.task?.subtasks.join(" | ") ?? "none"}`,
`Curator service: ${input.task?.serviceName ?? "none"}`,
`Expected completion events: ${input.task?.completionEvents.join(", ") ?? "none"}`,
`Captured task memory: ${input.taskMemory?.map((item) => `[${item.subtaskIndex ?? "?"}] ${item.subtask ?? "Subtask"}: ${item.summary}`).join(" | ") || "none"}`,
].join("\n");
];
pushField(lines, "Date", input.date);
pushField(lines, "Curator task id", input.taskId);
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
pushField(lines, "Focused subtask title", input.subtask);
pushField(lines, "Curator task title", input.task?.title);
pushField(lines, "Curator task context", input.task?.contextNarrative);
pushList(lines, "Curator task subtasks", input.task?.subtasks);
pushField(lines, "Curator service", input.task?.serviceName);
pushList(lines, "Expected completion events", input.task?.completionEvents);
const memory = input.taskMemory
?.map((item) => {
if (!item.summary) return "";
const subtask = item.subtask?.trim() || "Subtask";
const index = Number.isInteger(item.subtaskIndex) ? `[${item.subtaskIndex}] ` : "";
return `${index}${subtask}: ${item.summary}`;
})
.filter(Boolean);
pushList(lines, "Captured task memory", memory);
return lines.join("\n");
}
function curatorTaskKey(taskId?: string, subtaskIndex?: number) {
@@ -174,7 +208,9 @@ function firstTurnPrompt(input: {
}
function isExplicitHandoffRequest(text: string) {
return /\b(start|open|launch|begin|set up|setup|create|generate)\b/i.test(text);
const trimmed = text.trim();
if (/^start$/i.test(trimmed)) return false;
return /\b(start|open|launch|begin|set up|setup|create|generate)\b/i.test(trimmed);
}
async function evaluateSubtaskStatus(input: {
@@ -204,17 +240,19 @@ async function evaluateSubtaskStatus(input: {
"Never mark ready just because one message exists.",
"Use ASCII punctuation only.",
].join("\n"),
prompt: [
`Task title: ${input.task?.title ?? "none"}`,
`Task service: ${input.task?.serviceName ?? "none"}`,
`Focused subtask index: ${Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : "none"}`,
`Focused subtask: ${input.subtask}`,
`Task context: ${input.task?.contextNarrative ?? "none"}`,
`All task subtasks: ${input.task?.subtasks.join(" | ") ?? "none"}`,
`Latest user answer: ${input.latest}`,
`Assistant reply: ${input.reply}`,
`Visible history: ${input.history.map((message) => `${message.role}: ${message.content}`).join("\n")}`,
].join("\n"),
prompt: (() => {
const lines: string[] = [];
pushField(lines, "Task title", input.task?.title);
pushField(lines, "Task service", input.task?.serviceName);
pushField(lines, "Focused subtask index", Number.isInteger(input.subtaskIndex) ? input.subtaskIndex : undefined);
pushField(lines, "Focused subtask", input.subtask);
pushField(lines, "Task context", input.task?.contextNarrative);
pushList(lines, "All task subtasks", input.task?.subtasks);
pushField(lines, "Latest user answer", input.latest);
pushField(lines, "Assistant reply", input.reply);
pushField(lines, "Visible history", input.history.map((message) => `${message.role}: ${message.content}`).join("\n"));
return lines.join("\n");
})(),
});
return subtaskStatusUpdateSchema.parse(parseJsonObject(result.text));
} catch (error) {
@@ -294,7 +332,14 @@ export async function runCuratorChat(input: {
"Return JSON only: {\"summary\": string, \"userGoal\"?: string, \"serviceIntent\"?: string, \"shouldPrepareHandoff\": boolean}.",
"Use ASCII punctuation only.",
].join("\n"),
prompt: `Task: ${task?.title ?? "General curator chat"}\nSubtask: ${input.subtask ?? "none"}\nService: ${task?.serviceName ?? "none"}\nMessage: ${latest}`,
prompt: (() => {
const lines: string[] = [];
pushField(lines, "Task", task?.title);
pushField(lines, "Subtask", input.subtask);
pushField(lines, "Service", task?.serviceName);
pushField(lines, "Message", latest);
return lines.join("\n");
})(),
});
const parsedExtract = chatExtractSchema.parse(parseJsonObject(extract.text));
await emitCuratorEvent({

View File

@@ -92,13 +92,14 @@ type GeneratedTaskCopy = z.infer<typeof generatedTaskCopySchema>;
const taskCopyCache = new Map<string, GeneratedTaskCopy>();
function parseJsonObject(text: string) {
const trimmed = text.trim();
const trimmed = text.trim().replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
try {
return JSON.parse(trimmed);
} catch {
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) throw new Error("model_did_not_return_json");
return JSON.parse(match[0]);
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) throw new Error("model_did_not_return_json");
return JSON.parse(trimmed.slice(start, end + 1));
}
}
@@ -120,11 +121,9 @@ async function taskCopy(input: {
if (cached) return cached;
try {
const result = await generateText({
model: getConversationModel(),
system: [
const system = [
"You generate GrowQR V1 curator task copy.",
"Return JSON only. Do not wrap it in markdown.",
"Return valid JSON only. Do not wrap it in markdown.",
"The JSON shape must be: {\"title\": string, \"subtitle\": string, \"subtasks\": [string, string, string], \"contextNarrative\": string}.",
"Do not invent new missions or services. Use the provided mission, stage, role, and service only.",
"Generate exactly three sequential subtasks that make sense for a real user.",
@@ -133,37 +132,36 @@ async function taskCopy(input: {
"Do not include rewards, coins, scores, or generic readiness-score language.",
"Use ASCII punctuation only.",
"For early users, prefer practical context capture: goal, current background, resume/input, constraints, or service setup.",
].join("\n"),
prompt: [
`Mission: ${input.missionTitle}`,
`Stage: ${input.stageTitle}`,
`Stage description: ${input.stageDescription || "none"}`,
`Stage role: ${input.role ?? "none"}`,
`Service: ${input.serviceId ? serviceName(input.serviceId) : "Mission Planner"}`,
`Detected intent: ${stageIntent(input)}`,
].join("\n"),
});
let rawCopy = result.text;
let parsedCopy: unknown;
try {
parsedCopy = parseJsonObject(rawCopy);
} catch (parseError) {
const repaired = await generateText({
].join("\n");
const basePrompt = (() => {
const lines = [
`Mission: ${input.missionTitle}`,
`Stage: ${input.stageTitle}`,
`Service: ${input.serviceId ? serviceName(input.serviceId) : "Mission Planner"}`,
`Detected intent: ${stageIntent(input)}`,
];
if (input.stageDescription.trim()) lines.push(`Stage description: ${input.stageDescription}`);
if (input.role?.trim()) lines.push(`Stage role: ${input.role}`);
return lines.join("\n");
})();
let lastError = "";
for (let attempt = 0; attempt < 3; attempt += 1) {
const result = await generateText({
model: getConversationModel(),
system: [
"Repair invalid JSON into valid JSON only.",
"Do not add markdown.",
"Do not add or invent new task content.",
"Keep the exact object shape: {\"title\": string, \"subtitle\": string, \"subtasks\": [string, string, string], \"contextNarrative\": string}.",
].join("\n"),
prompt: rawCopy,
system,
prompt: attempt === 0
? basePrompt
: `${basePrompt}\n\nPrevious JSON was invalid: ${lastError}\nRegenerate the same task copy as valid JSON only. Do not add comments or markdown.`,
});
rawCopy = repaired.text;
parsedCopy = parseJsonObject(rawCopy);
try {
const copy = generatedTaskCopySchema.parse(parseJsonObject(result.text));
taskCopyCache.set(cacheKey, copy);
return copy;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
const copy = generatedTaskCopySchema.parse(parsedCopy);
taskCopyCache.set(cacheKey, copy);
return copy;
throw new Error(`curator_task_copy_invalid_json: ${lastError}`);
} catch (error) {
console.warn("curator task copy generation failed", {
missionTitle: input.missionTitle,