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.Layerinterface here.api/src/conversations/assembler.service.spec.ts— pure unit test (feed structured input, assert layers + assembled_context; mirrorapi/test/brief-assembler.spec.ts).
Modify:
api/src/feature-flags/feature-flags.constants.ts— add"prompt_assembler_enabled"toFLAG_KEYS+DEFAULT_FLAG_VALUES(false).api/src/conversations/conversations.service.ts—resolvePromptAssemblerEnabled(mirrorresolvePersonaOverrideMode:5411); at the two dispatch sites (:3175,:6400) flag-ON: callassembler.assemble(...)→ injectagentContext.assembled_prompt+prompt_assembler_version; gate thehistory.unshiftKB injection (:3025) OFF when assembling.api/src/conversations/conversations.module.ts— registerAssemblerService.agent/chat_helpers.py— executor branch at top ofbuild_hermes_prompt_with_meta(~:289): ifagent_context.assembled_prompt.assembled_contextpresent, use it assystem_prompt(skipbuild_system_prompt_with_meta+load_org_soul); gatekb_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(addresolvePromptAssemblerEnabled) -
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.tsstill green (new key seeded byseedDefaults).npx tsc --noEmitclean. -
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 --noEmitclean. -
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(:3175main dispatch,:6400regenerate; 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
ConversationsServiceconstructor (@Optional()— lean test modules), register inconversations.module.tsproviders. -
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 --noEmitclean. -
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-runagent/evals/test_prompt_echo_guard.py -
Step 1: Read
chat_helpers.py:267-459(join list + wherebuild_system_prompt_with_metais called ~:289, whereload_org_soulfeeds it ~:294) andagent/evals/test_chat_helpers.py(_req()factory,SimpleNamespaceagent_context,monkeypatchofload_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 thebuild_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-runagent/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_soulNOT 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.
- KB: flag ON → assembled_context has KB, history has NO
-
Step 2: Verify flag-OFF byte-equality — run the full conversations suite +
agent/evals/test_chat_helpers.pybaseline; 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: documentprompt_assembler_enabled(default OFF); flag ON routes prompt assembly throughAssemblerService(produces{assembled_context, layer_manifest}viaagent_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 inbuild_hermes_prompt_with_meta— whenagent_context.assembled_promptpresent, use itsassembled_contextas system_prompt, skipbuild_system_prompt_with_meta+load_org_soul, still append runtime tail (inbound/extracted/web/history/user). additiveagent_contextkey (缺键=legacy, mirrorspersona_override_mode).
-
Step 4: Self-check no machine paths —
grep -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 inpersona-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.