From 7d9fe7aa43636179f3bca4e68267b1be484440ce Mon Sep 17 00:00:00 2001 From: Sebastian Tsang Date: Sat, 9 May 2026 21:45:33 -0400 Subject: [PATCH] fix(runtime): thread founder prompt to every specialist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Conductor saw the founder's prompt and produced a DAG, but the dispatcher dropped the prompt before invoking specialists. So a prompt like "write an email to X — keep it 2 sentences and friendly" reached the writer as just `{leadId, item}`, with no signal about length, tone, or angle. The writer would produce its default outreach instead of honoring the user's instruction. Threads `founderObjective` (the run's prompt) through: runWorkflow → runPersonaWithFallback → runPersona / runPersonaBatch Both `buildUserPrompt` and `buildBatchUserPrompt` now prepend a "Founder objective" section when present, with explicit guidance that it MAY override default behavior (length/tone/channel/angle). The persona's own system prompt still defines its job; the founder prompt becomes a per-run constraint layer on top. Backwards-compatible: `founderObjective` is optional; ad-hoc callers (test harness, scripts) work unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/personas/runtime.ts | 50 ++++++++++++++++++++++++++++++++--------- lib/state/workflows.ts | 18 ++++++++++----- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/lib/personas/runtime.ts b/lib/personas/runtime.ts index b91e761..06ef20a 100644 --- a/lib/personas/runtime.ts +++ b/lib/personas/runtime.ts @@ -49,6 +49,14 @@ export async function runPersona( personaId: PersonaId, input: Record & { workflowRunId?: string; nodeId?: string }, userId: string, + /** + * The founder's original prompt that triggered this run. Threaded into the + * specialist's user-prompt as a top-level "Founder objective" section so + * persona-level reasoning can honor specific instructions ("keep it 2 + * sentences and friendly", "use a sport-themed angle", …) that the + * Conductor caught but would otherwise be dropped between L1 and L2. + */ + founderObjective?: string, ): Promise { const persona = PERSONA_REGISTRY[personaId]; @@ -81,7 +89,7 @@ export async function runPersona( raw = await Promise.race([ collectFinalText( query({ - prompt: buildUserPrompt(personaId, parsedInput), + prompt: buildUserPrompt(personaId, parsedInput, founderObjective), options: { model: getModelForTier(persona.modelTier), systemPrompt: promptBody, @@ -187,7 +195,16 @@ export async function runPersonaBatch( personaId: PersonaId, envelopes: BatchItemEnvelope[], userId: string, - ctx: { workflowRunId?: string; nodeId?: string } = {}, + ctx: { + workflowRunId?: string; + nodeId?: string; + /** + * The founder's original prompt — see runPersona's `founderObjective` doc. + * Threaded into the batch user-prompt so cross-item reasoning can honor + * the run-level intent ("filter to fintech leads", "skip churned trials"). + */ + founderObjective?: string; + } = {}, ): Promise> { const persona = PERSONA_REGISTRY[personaId]; if (!persona.batchInputSchema || !persona.batchOutputSchema) { @@ -222,6 +239,7 @@ export async function runPersonaBatch( mcpConfig, workflowRunId, nodeId, + ctx.founderObjective, ); const expectedIds = new Set(envelopes.map((e) => e.id)); @@ -248,6 +266,7 @@ export async function runPersonaBatch( mcpConfig, workflowRunId, nodeId, + ctx.founderObjective, ); for (const [k, v] of retryResult.items) collected.items.set(k, v); if (retryResult.mergedGroups?.length) { @@ -283,6 +302,7 @@ async function runBatchAttempt( mcpConfig: Awaited>, workflowRunId: string, nodeId: string, + founderObjective?: string, ): Promise> { const items = envelopes.map((e) => ({ ...e.payload, [keyField(e)]: e.id })); const inputForValidation = { items, workflowRunId, nodeId }; @@ -293,7 +313,7 @@ async function runBatchAttempt( throw new PersonaRuntimeError(personaId, "input", e); } - const userPrompt = buildBatchUserPrompt(personaId, items); + const userPrompt = buildBatchUserPrompt(personaId, items, founderObjective); let raw: string; try { @@ -377,6 +397,7 @@ function keyField(envelope: BatchItemEnvelope): string { function buildBatchUserPrompt( personaId: PersonaId, items: Array>, + founderObjective?: string, ): string { // Pure-LLM personas (allowedTools=[]) get a different shape: no tool-calling // dance, just "produce JSON for all N items at once." Tool-coupled batch @@ -386,10 +407,15 @@ function buildBatchUserPrompt( const persona = PERSONA_REGISTRY[personaId]; const isPureLLM = (persona.allowedActions ?? []).length === 0; + const objectiveBlock = + founderObjective && founderObjective.trim().length > 0 + ? `\nFounder objective (the original user prompt for this run — apply any constraints it sets, e.g. tone, length, channel, angle):\n${founderObjective.trim()}\n` + : ""; + if (isPureLLM) { return [ `Persona: ${personaId} (BATCH MODE — ${items.length} items)`, - "", + objectiveBlock, "You are a pure reasoner — you have NO tools available. Reason over", "every item in the array below at once and emit a SINGLE final assistant", "message whose entire body is a fenced JSON block:", @@ -416,7 +442,7 @@ function buildBatchUserPrompt( return [ `Persona: ${personaId} (BATCH MODE — ${items.length} items)`, - "", + objectiveBlock, "Step 1: Issue exactly ONE call to mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL", " with one sub-invocation per item. Do NOT make separate tool calls", " for each item — that defeats the batch optimization.", @@ -448,21 +474,25 @@ function buildBatchUserPrompt( function buildUserPrompt( personaId: PersonaId, input: Record, + founderObjective?: string, ): string { const { previousOutputs, ...rest } = input as Record & { previousOutputs?: Record>; }; - const sections: string[] = [ - `Persona: ${personaId}`, - `Input (JSON): ${JSON.stringify(rest)}`, - ]; + const sections: string[] = [`Persona: ${personaId}`]; + if (founderObjective && founderObjective.trim().length > 0) { + sections.push( + `Founder objective (the original user prompt that triggered this run — honor any constraints it gives you, e.g. tone, length, channel, angle):\n${founderObjective.trim()}`, + ); + } + sections.push(`Input (JSON): ${JSON.stringify(rest)}`); if (previousOutputs && Object.keys(previousOutputs).length > 0) { sections.push( `Upstream task outputs (previousOutputs) — reference these when your system prompt needs an artifact id (e.g. previousOutputs.writer.draftId) from a dependency:\n${JSON.stringify(previousOutputs, null, 2)}`, ); } sections.push( - "Follow your system prompt. Return ONLY a JSON object (or fenced ```json block) matching your output schema.", + "Follow your system prompt. The founder objective above MAY override or refine your default behavior (length, tone, channel) — apply it when present. Return ONLY a JSON object (or fenced ```json block) matching your output schema.", ); return sections.join("\n\n"); } diff --git a/lib/state/workflows.ts b/lib/state/workflows.ts index 0418a80..66fccb5 100644 --- a/lib/state/workflows.ts +++ b/lib/state/workflows.ts @@ -58,6 +58,7 @@ async function runPersonaWithFallback( personaId: PersonaId, input: Record & { workflowRunId?: string; nodeId?: string }, founderId: string, + founderObjective?: string, ): Promise> { if (shouldUseMockPersonas()) { const wfId = input.workflowRunId ?? "ad-hoc"; @@ -83,10 +84,12 @@ async function runPersonaWithFallback( }); return out ?? {}; } - return (await runPersona(personaId, input, founderId)) as Record< - string, - unknown - >; + return (await runPersona( + personaId, + input, + founderId, + founderObjective, + )) as Record; } function nodeRowId(workflowRunId: string, taskId: string): string { @@ -933,7 +936,11 @@ export async function runWorkflow( task.specialistId, enriched, founderId, - { workflowRunId, nodeId: `${task.specialistId}__BATCH` }, + { + workflowRunId, + nodeId: `${task.specialistId}__BATCH`, + founderObjective: prompt, + }, ); batchGroupPromises.set(groupKey, groupPromise); } @@ -1012,6 +1019,7 @@ export async function runWorkflow( task.specialistId, inputForRun, founderId, + prompt, ); await markNodeDone(nodeId); const finalOut =