Skip to main content

P3.1 — NestJS Assembler + flag-gated prompt contract Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax.

Goal: Build a deterministic AssemblerService in NestJS that produces {assembled_context, layer_manifest} (ordered layers, priority, per-layer token budget, neverTruncate for red-lines/identity per NFR3), wire it into the two /chat dispatch sites behind a default-OFF feature flag, and add an agent-side executor branch that consumes the assembled prompt (skipping self-assembly + magnetic soul read) then appends runtime-only tail blocks and shells out Hermes -q.

Architecture: Contract-flip (design docs/superpowers/specs/2026-07-08-phase3-assembly-design.md §6). prompt_assembler_enabled default OFF → zero production behavior change until explicitly enabled per-Specialist. flag ON: NestJS assembles the deterministic body (persona/soul/directives/KB/correction/response-contract in priority order), passes it additively via agent_context.assembled_prompt; agent short-circuits its self-assembly, keeps runtime-only tail blocks (inbound-file paths, extracted-docs, web_context — NestJS can't know these pre-call), collapses to one Hermes -q string.

Tech Stack: NestJS 11 (AssemblerService, FeatureFlagService), agent Python (chat_helpers.py/prompts_loader.py), Jest + agent evals.

Scope note: Long-lived branch feat/specialist-value-output-p2plus. P3.1 is the ROOT of Phase 3 (P3.2 trigger / P3.3 cascade / P3.4 trace / P3.5 redline depend on it). This plan is P3.1 ONLY. Commit per sub-task.

Boundary (P3.1 does NOT do): does NOT move the 6 existing private assembly methods out of ConversationsService (that would break persona-override-mode.spec.ts / specialist-template-key.spec.ts etc.); does NOT converge retrieval cascade (P3.3); does NOT add trigger/skill consumption (P3.2); does NOT add redline checks (P3.5). AssemblerService is a flag-ON bypass that consumes data ConversationsService already computed.

Pre-existing baseline: verify failures against branch base 490517fb. NestJS local jest ~21 known pre-existing failures; agent evals run offline. Only NEW failures blocking. Prompt-shape changes MUST re-run agent/evals/test_prompt_echo_guard.py + CoT-leak regressions (CLAUDE.md #4012/#3977/#4021).


File Structure

Create:

  • api/src/conversations/assembler.service.ts@Injectable() AssemblerService.assemble(input): {assembled_context, layer_manifest}. Pure assembly (order/priority/budget/neverTruncate); NO data fetching. Layer interface here.
  • api/src/conversations/assembler.service.spec.ts — pure unit test (feed structured input, assert layers + assembled_context; mirror api/test/brief-assembler.spec.ts).

Modify:

  • api/src/feature-flags/feature-flags.constants.ts — add "prompt_assembler_enabled" to FLAG_KEYS + DEFAULT_FLAG_VALUES (false).
  • api/src/conversations/conversations.service.tsresolvePromptAssemblerEnabled (mirror resolvePersonaOverrideMode:5411); at the two dispatch sites (:3175, :6400) flag-ON: call assembler.assemble(...) → inject agentContext.assembled_prompt + prompt_assembler_version; gate the history.unshift KB injection (:3025) OFF when assembling.
  • api/src/conversations/conversations.module.ts — register AssemblerService.
  • agent/chat_helpers.py — executor branch at top of build_hermes_prompt_with_meta (~:289): if agent_context.assembled_prompt.assembled_context present, use it as system_prompt (skip build_system_prompt_with_meta + load_org_soul); gate kb_context/directives_block (data already in assembled_context); keep inbound/extracted/web tail.
  • api/src/conversations/CLAUDE.md, agent/CLAUDE.md — document the flag-gated assembler path.

Dependency direction: unchanged wire (additive agent_context key, like persona_override_mode). No /chat top-level field added. AssemblerService lives in ConversationsModule (consumes its already-computed data as input params).


Task 1: feature flag prompt_assembler_enabled (default OFF) + resolver

Files:

  • Modify: api/src/feature-flags/feature-flags.constants.ts

  • Modify: api/src/conversations/conversations.service.ts (add resolvePromptAssemblerEnabled)

  • Test: api/src/conversations/ (new resolver test, mirror persona-override)

  • Step 1: Read the flag precedent

Read feature-flags.constants.ts (FLAG_KEYS tuple + DEFAULT_FLAG_VALUES record — note TS compile-enforces every key has a default) and conversations.service.ts:5411-5424 (resolvePersonaOverrideMode — the exact this.featureFlags?.resolve(...) + try/catch fail-safe pattern) and :5387-5401 (resolveSpecialistAssignmentId).

  • Step 2: Write the failing resolver test

Mirror persona-override-mode.spec.ts's flagResolver stub. Add a test that resolvePromptAssemblerEnabled(orgId, specialistId) returns false when the flag resolves disabled / missing (default), true when enabled, and false on resolver throw (fail-safe). Since the method is private, drive it via the dispatch path OR test through a thin public wrapper — mirror how persona-override-mode.spec.ts asserts the resolved mode reaches the payload. Run → FAIL (method absent).

  • Step 3: Add the flag

In feature-flags.constants.ts: add "prompt_assembler_enabled" to FLAG_KEYS (with a comment: "P3.1 — route /chat prompt assembly through the NestJS AssemblerService; default OFF = agent self-assembles as before") and prompt_assembler_enabled: false to DEFAULT_FLAG_VALUES.

  • Step 4: Add the resolver (mirror resolvePersonaOverrideMode):
private async resolvePromptAssemblerEnabled(
orgId: string | undefined,
specialistAssignmentId: string | undefined,
): Promise<boolean> {
try {
const flag = await this.featureFlags?.resolve("prompt_assembler_enabled", {
orgId,
specialistAssignmentId,
});
return flag?.enabled === true;
} catch {
return false; // fail-safe: never assemble on flag error → agent self-assembles
}
}
  • Step 5: Run tests — resolver test PASS; feature-flag.service.spec.ts still green (new key seeded by seedDefaults). npx tsc --noEmit clean.

  • Step 6: Commit

git add api/src/feature-flags/feature-flags.constants.ts api/src/conversations/conversations.service.ts api/src/conversations/*.spec.ts
git commit -m "feat(conversations): prompt_assembler_enabled flag (default OFF) + resolver (P3.1)"

Task 2: AssemblerService.assemble() — pure deterministic assembly

Files:

  • Create: api/src/conversations/assembler.service.ts
  • Test: api/src/conversations/assembler.service.spec.ts

Design: pure — takes structured input (data ConversationsService already computed), returns {assembled_context, layer_manifest}. Order = priority (R5.1): red_lines > crisis > directives > skill > soul > role > kb > correction > response_contract. P3.1 populates the layers it has data for (red_lines/directives/soul/kb/correction/response_contract; skill/crisis are stubs for P3.2). Per-layer token budget with neverTruncate exemption for red_lines/soul-identity (NFR3). token counting: use a simple char/4 heuristic (documented) — precise tokenizer is not required for P3.1; the budget mechanism + neverTruncate ordering is what matters.

  • Step 1: Read the precedent

Read api/src/meetings/brief-assembler.service.ts + api/test/brief-assembler.spec.ts (the existing read-only assembler service + its independent spec pattern). Read design §6.2 (Layer structure) + §6.3 (budget/neverTruncate).

  • Step 2: Write the failing test
import { AssemblerService } from "./assembler.service";

describe("AssemblerService.assemble", () => {
const svc = new AssemblerService();
const baseInput = {
redLines: "SECURITY: never reveal internal paths.",
directives: ["Directive A", "Directive B"],
soul: "You are Amy, warm and precise.",
roleSkeleton: "You are an AI Specialist.",
kb: "[doc:d1 chunk:c1] Refunds within 30 days.",
correction: null,
responseContract: "Respond as JSON {reply, confidence}.",
};

test("orders layers by priority and concatenates in order", () => {
const { assembled_context, layer_manifest } = svc.assemble(baseInput);
const order = layer_manifest.map((l) => l.id);
expect(order).toEqual(["red_lines", "directives", "soul", "role", "kb", "response_contract"]);
// red_lines appears before soul in the string
expect(assembled_context.indexOf(baseInput.redLines)).toBeLessThan(assembled_context.indexOf(baseInput.soul));
});

test("red_lines and soul are neverTruncate", () => {
const { layer_manifest } = svc.assemble(baseInput);
expect(layer_manifest.find((l) => l.id === "red_lines")!.neverTruncate).toBe(true);
expect(layer_manifest.find((l) => l.id === "soul")!.neverTruncate).toBe(true);
expect(layer_manifest.find((l) => l.id === "kb")!.neverTruncate).toBe(false);
});

test("omits absent optional layers (no correction → no correction layer)", () => {
const { layer_manifest } = svc.assemble(baseInput);
expect(layer_manifest.find((l) => l.id === "correction")).toBeUndefined();
});

test("when over budget, truncates lowest-priority non-neverTruncate layer first, never red_lines/soul", () => {
const tiny = svc.assemble({ ...baseInput, kb: "x".repeat(100_000), maxTokens: 200 });
// red_lines + soul survive intact; kb is what got cut
expect(tiny.assembled_context).toContain(baseInput.redLines);
expect(tiny.assembled_context).toContain(baseInput.soul);
const kbLayer = tiny.layer_manifest.find((l) => l.id === "kb")!;
expect(kbLayer.charCount).toBeLessThan(100_000);
});

test("carries sourceRefs for kb layer (chunk ids for P3.4 trace)", () => {
const { layer_manifest } = svc.assemble({ ...baseInput, kbSourceRefs: ["d1:c1"] });
expect(layer_manifest.find((l) => l.id === "kb")!.sourceRefs).toEqual(["d1:c1"]);
});
});

Run → FAIL (module absent).

  • Step 3: Implement assembler.service.ts
import { Injectable } from "@nestjs/common";

export type LayerId =
| "red_lines" | "crisis" | "directives" | "skill" | "soul"
| "role" | "kb" | "correction" | "response_contract";

export interface Layer {
id: LayerId;
priority: number; // smaller = earlier + more protected
neverTruncate: boolean;
tokenBudget: number | null;
charCount: number;
sourceRefs?: string[];
}

export interface AssembleInput {
redLines?: string | null;
crisis?: string | null;
directives?: string[];
skill?: string | null; // P3.2 populates; P3.1 stub
soul?: string | null;
roleSkeleton?: string | null;
kb?: string | null;
kbSourceRefs?: string[];
correction?: string | null;
responseContract?: string | null;
maxTokens?: number; // total budget; default a safe large number
}

export interface AssembleResult {
assembled_context: string;
layer_manifest: Layer[];
}

const APPROX_CHARS_PER_TOKEN = 4; // documented heuristic; precise tokenizer is P3.4+ if needed

@Injectable()
export class AssemblerService {
assemble(input: AssembleInput): AssembleResult {
// Priority order (R5.1): red_lines > crisis > directives > skill > soul > role > kb > correction > response_contract
const spec: Array<{ id: LayerId; text: string | null | undefined; neverTruncate: boolean; sourceRefs?: string[] }> = [
{ id: "red_lines", text: input.redLines, neverTruncate: true },
{ id: "crisis", text: input.crisis, neverTruncate: true },
{ id: "directives", text: (input.directives?.length ? "ACTIVE DIRECTIVES — HIGHEST PRIORITY:\n" + input.directives.join("\n") : null), neverTruncate: false },
{ id: "skill", text: input.skill, neverTruncate: false },
{ id: "soul", text: input.soul, neverTruncate: true },
{ id: "role", text: input.roleSkeleton, neverTruncate: false },
{ id: "kb", text: input.kb, neverTruncate: false, sourceRefs: input.kbSourceRefs },
{ id: "correction", text: input.correction, neverTruncate: false },
{ id: "response_contract", text: input.responseContract, neverTruncate: false },
];

const present = spec
.map((s, i) => ({ ...s, priority: i }))
.filter((s) => s.text != null && s.text.length > 0) as Array<
{ id: LayerId; text: string; neverTruncate: boolean; sourceRefs?: string[]; priority: number }
>;

const budgetChars = (input.maxTokens ?? 1_000_000) * APPROX_CHARS_PER_TOKEN;

// Truncate lowest-priority non-neverTruncate layers first until within budget.
const total = () => present.reduce((n, l) => n + l.text.length, 0);
if (total() > budgetChars) {
const cuttable = present
.filter((l) => !l.neverTruncate)
.sort((a, b) => b.priority - a.priority); // lowest priority (largest index) first
for (const layer of cuttable) {
if (total() <= budgetChars) break;
const over = total() - budgetChars;
const cut = Math.min(over, layer.text.length);
layer.text = layer.text.slice(0, layer.text.length - cut);
}
}

const assembled_context = present.map((l) => l.text).join("\n\n");
const layer_manifest: Layer[] = present.map((l) => ({
id: l.id,
priority: l.priority,
neverTruncate: l.neverTruncate,
tokenBudget: null,
charCount: l.text.length,
...(l.sourceRefs ? { sourceRefs: l.sourceRefs } : {}),
}));

return { assembled_context, layer_manifest };
}
}
  • Step 4: Run test → PASS (5). npx tsc --noEmit clean.

  • Step 5: Commit

git add api/src/conversations/assembler.service.ts api/src/conversations/assembler.service.spec.ts
git commit -m "feat(conversations): AssemblerService — deterministic layered prompt assembly (P3.1)"

Task 3: wire assembler into the two dispatch sites (flag-gated) + register in module

Files:

  • Modify: api/src/conversations/conversations.service.ts (:3175 main dispatch, :6400 regenerate; gate KB unshift :3025)

  • Modify: api/src/conversations/conversations.module.ts (register AssemblerService)

  • Test: new spec mirroring persona-override-mode.spec.ts

  • Step 1: Read the dispatch blocks + KB inject + module (conversations.service.ts:3175-3199, :6400-6421, :3014-3028, conversations.module.ts).

  • Step 2: Write the failing test (mirror persona-override-mode.spec.ts)

Assert: with prompt_assembler_enabled ON, orgAgentClient.chat is called with agentContext containing assembled_prompt: { assembled_context, layer_manifest } + prompt_assembler_version, AND the KB <kb_context> user-turn is NOT unshifted into history (gated). With flag OFF: NO assembled_prompt key AND the KB user-turn IS present (byte-equal to current). Run → FAIL.

  • Step 3: Inject AssemblerService into ConversationsService constructor (@Optional() — lean test modules), register in conversations.module.ts providers.

  • Step 4: Wire the gate at BOTH dispatch sites. Before each orgAgentClient.chat({...}):

const assemblerEnabled = await this.resolvePromptAssemblerEnabled(dispatchOrgId, specialistAssignmentId);
let assembledPrompt: { assembled_context: string; layer_manifest: Layer[] } | undefined;
if (assemblerEnabled && this.assembler) {
assembledPrompt = this.assembler.assemble({
redLines: null, // P3.1: agent still owns SECURITY_PREFIX const; NestJS red_lines wired in P3.2/P3.5
directives: activeDirectives,
soul: specialistSystemPrompt, // NestJS-rendered persona (resolveSpecialistPersona + memory)
kb: retrievedKbContext.length ? this.formatKbContext(retrievedKbContext) : null,
kbSourceRefs: retrievedKbContext.map((r) => `${r.document_id}:${r.chunk_id ?? ""}`),
correction: /* correction few-shot text if injected, else null */ null,
responseContract: null, // agent owns response_contract const in P3.1
});
}

Then in the agentContext: spread ...(assembledPrompt ? { assembled_prompt: assembledPrompt, prompt_assembler_version: 1 } : {}).

Gate the KB unshift (:3025): wrap history.unshift({role:"user", content: this.formatKbContext(...)}) in if (!assemblerEnabled) { ... } — when assembling, KB lives in assembled_context, not the history turn (dual-injection mutual-exclusion, design §6.4). Do the same for the correction few-shot unshift if it would double.

Note (regenerate path): kbContextProvided is false there (:6471) → retrievedKbContext empty → kb layer absent. That's correct (no KB retrieval in regenerate); do not error on empty KB.

Note (P0.6 trace): ensure the assembler path still produces agentResp.promptVersion so metadata.assembly (:3224) isn't silently dropped — the agent returns it (Task 4 keeps meta flowing).

  • Step 5: Run tests — new spec PASS (ON injects assembled_prompt + gates KB; OFF unchanged); existing persona-override-mode.spec.ts / specialist-template-key.spec.ts / conversations suite still green (OFF = byte-equal). npx tsc --noEmit clean.

  • Step 6: Commit

git add api/src/conversations/conversations.service.ts api/src/conversations/conversations.module.ts api/src/conversations/*.spec.ts
git commit -m "feat(conversations): flag-gated assembler wiring at dispatch sites + KB dual-inject gate (P3.1)"

Task 4: agent-side executor branch

Files:

  • Modify: agent/chat_helpers.py (build_hermes_prompt_with_meta ~:289)

  • Test: agent/evals/test_chat_helpers.py + re-run agent/evals/test_prompt_echo_guard.py

  • Step 1: Read chat_helpers.py:267-459 (join list + where build_system_prompt_with_meta is called ~:289, where load_org_soul feeds it ~:294) and agent/evals/test_chat_helpers.py (_req() factory, SimpleNamespace agent_context, monkeypatch of load_org_soul).

  • Step 2: Write the failing test in test_chat_helpers.py

def test_executor_branch_uses_assembled_context(monkeypatch):
soul_calls = []
monkeypatch.setattr("agent.chat_helpers.load_org_soul", lambda *a, **k: soul_calls.append(1) or "MAGNETIC SOUL")
req = _req(message="hi", agent_context={
"assembled_prompt": {"assembled_context": "ASSEMBLED-BODY", "layer_manifest": [{"id": "soul", "neverTruncate": True}]},
})
prompt, meta = build_hermes_prompt_with_meta(req, inbound_records=[], extracted_docs=[])
assert "ASSEMBLED-BODY" in prompt # executor uses NestJS body
assert "MAGNETIC SOUL" not in prompt # soul NOT double-read from disk
assert soul_calls == [] # load_org_soul skipped (dual-source mutual-exclusion)
assert prompt.rstrip().endswith("User: hi") # runtime tail (user msg) still appended

def test_executor_branch_keeps_runtime_tail(monkeypatch):
req = _req(message="q", agent_context={"assembled_prompt": {"assembled_context": "BODY", "layer_manifest": []}})
prompt, meta = build_hermes_prompt_with_meta(
req, inbound_records=[{"path": "workspace/inbound/file.pdf", "name": "file.pdf"}], extracted_docs=[])
assert "file.pdf" in prompt # inbound tail preserved

def test_no_assembled_prompt_is_byte_equal_to_current(monkeypatch):
# baseline: without assembled_prompt key, output identical to legacy self-assembly
req_legacy = _req(message="hi", agent_context={})
# (assert against the existing golden/legacy assertion already in this file)

Run → FAIL (executor branch absent; soul still read).

  • Step 3: Implement the executor branch at the top of build_hermes_prompt_with_meta, before the build_system_prompt_with_meta(...) call (~:289):
    assembled = None
if isinstance(req.agent_context, dict):
ap = req.agent_context.get("assembled_prompt")
if isinstance(ap, dict) and ap.get("assembled_context"):
assembled = ap

if assembled:
# Executor path (P3.1): NestJS AssemblerService already built the
# deterministic body (red-lines/soul/directives/KB/... in priority order).
# Skip self-assembly AND load_org_soul (soul is in assembled_context —
# reading disk soul here would double the persona; design §6.4).
system_prompt = assembled["assembled_context"]
assembly_meta = _assembly_meta_from_manifest(ap.get("layer_manifest", []))
# KB + directives are already inside assembled_context → do NOT re-render
# req.kb_context / req.active_directives below.
else:
system_prompt, assembly_meta = build_system_prompt_with_meta(
... # unchanged existing call incl. soul_content=load_org_soul()
)

Then in the join list (~:430), when assembled is set, omit kb_context (line 298 var) and directives_block (line 305 var) — they're in system_prompt now. Keep web_context, request-context, history, inbound-files, extracted-docs, response_contract, clarity_signal, user message (runtime tail). Add a small helper _assembly_meta_from_manifest(manifest) returning {template_used: "assembler", persona_override_applied: False, layers: [l["id"] for l in manifest]} so P0.6 trace stays populated.

  • Step 4: Run test_chat_helpers.py → PASS; re-run agent/evals/test_prompt_echo_guard.py + test_prompt_assembly_meta.py → PASS (no echo/CoT-leak regression from the new branch).

  • Step 5: Commit

git add agent/chat_helpers.py agent/evals/test_chat_helpers.py
git commit -m "feat(agent): executor branch consuming NestJS assembled_prompt (skip self-assembly + disk soul) (P3.1)"

Task 5: dual-source mutual-exclusion verification + docs

Files:

  • Test: cross-cutting assertions (may extend Task 3 + Task 4 specs)

  • Modify: api/src/conversations/CLAUDE.md, agent/CLAUDE.md

  • Step 1: Verify the three mutual-exclusions hold end-to-end

    • KB: flag ON → assembled_context has KB, history has NO <kb_context> turn (Task 3 gate). flag OFF → history HAS it, no assembled_prompt. Assert exactly one KB copy in each mode.
    • soul: flag ON → agent load_org_soul NOT called (Task 4 test). flag OFF → called as before.
    • directives: flag ON → in assembled_context, agent omits format_directives(req.active_directives). flag OFF → agent renders them. Add any missing assertion to the Task 3 / Task 4 specs.
  • Step 2: Verify flag-OFF byte-equality — run the full conversations suite + agent/evals/test_chat_helpers.py baseline; confirm OFF path output is byte-identical to pre-P3.1 (the whole safety guarantee). Any drift = a bug in the gating.

  • Step 3: Docs

    • conversations/CLAUDE.md: document prompt_assembler_enabled (default OFF); flag ON routes prompt assembly through AssemblerService (produces {assembled_context, layer_manifest} via agent_context.assembled_prompt), gating the KB user-turn + directives to avoid dual-injection; OFF = agent self-assembles (byte-equal to legacy). Note the soul dual-source rule (agent skips disk soul when assembling).
    • agent/CLAUDE.md: document the executor branch in build_hermes_prompt_with_meta — when agent_context.assembled_prompt present, use its assembled_context as system_prompt, skip build_system_prompt_with_meta + load_org_soul, still append runtime tail (inbound/extracted/web/history/user). additive agent_context key (缺键=legacy, mirrors persona_override_mode).
  • Step 4: Self-check no machine pathsgrep -rnE '/Users/|/home/|/private/tmp' <changed CLAUDE.md files>; echo exit=$? → 1.

  • Step 5: Commit

git add api/src/conversations/CLAUDE.md agent/CLAUDE.md api/src/conversations/*.spec.ts agent/evals/*.py
git commit -m "test+docs(P3.1): dual-source mutual-exclusion + flag-OFF byte-equality + assembler contract docs"

Task 6: full verification

  • Step 1: cd api && npx tsc --noEmit && npm run lint → clean (no new lint warnings — hard budget).
  • Step 2: npx jest src/conversations/ src/feature-flags/ --runInBand → pass; flag-OFF byte-equality holds; no regression in persona-override-mode/specialist-template-key.
  • Step 3: cd agent && python -m pytest evals/test_chat_helpers.py evals/test_prompt_echo_guard.py evals/test_prompt_assembly_meta.py -q → pass.
  • Step 4: No commit — verification only. Proceed to Phase-3-task-level review.

Self-Review Checklist

Spec coverage: deterministic layered assembly + priority + budget + neverTruncate (Task 2) ✅; flag-gated wiring, default OFF (Task 1+3) ✅; agent executor branch (Task 4) ✅; dual-source mutual-exclusion KB/soul/directives (Task 3+4+5) ✅; flag-OFF zero-behavior-change (Task 5 byte-equality) ✅.

Type consistency: Layer/AssembleInput/AssembleResult defined Task 2, consumed Task 3. assembled_prompt wire key (Task 3) matches agent read (Task 4). prompt_assembler_version: 1 set Task 3.

Boundary: no existing private method moved; AssemblerService is flag-ON bypass; red_lines/response_contract still agent-owned consts in P3.1 (NestJS wiring of those is P3.2/P3.5); retrieval cascade convergence is P3.3. skill/crisis layers are stubs (P3.2 populates).

Safety net: flag default OFF → production byte-identical. Rollback = leave flag OFF. Echo-guard re-run mandatory.