20 KiB
H-AUDIT-1 Design Doc — turnId Propagation + Stage 2 reasoning_content
Datum: 2026-04-22
Sprint: 11 · Track A · Task A1
Authority chain: briefs/2026-04-22-cc-sprint-11-kickoff.md §3 Track A A1 + decisions/2026-04-22-stage-2-primary-config-locked.md §5 (reasoning_content handling extension)
Author: CC-1
Status: DRAFT — awaiting PM ratification before A2 implementation
Supersedes memory claim: project_h_audit_1_not_implemented.md — stale. Production chat stack turnId propagation was landed in a prior sprint; see §1 state audit below.
0. Executive summary
Production-chat turnId propagation, the grep ≥6 acceptance target, the full-turn-graph reconstruction test, and the regression guard for the six target files are already landed and green on origin/main as of e1ae0a4. The live surface is 7 files in packages/agent/src + 2 files in packages/server/src = 9 files with turnId references. The existing Vitest suite packages/agent/tests/turn-context.test.ts asserts all four brief acceptance items in isolated test cases.
A2 net-new scope is therefore narrowed to one concern: reasoning_content handling for Stage 2 thinking=on, max_tokens=64000 on qwen3.6-35b-a3b-via-openrouter, per decision doc §5. The Stage 2 batch path runs through benchmarks/harness/src/llm.ts (not the production chat stack), so reasoning_content capture + turnId correlation lands in the harness + its JSONL records, with an explicit rule on production-chat behavior documented below.
1. Current state audit (as of 2026-04-22, HEAD = e1ae0a4)
1.1 turnId generator
File: packages/agent/src/turn-context.ts:29
Contract: export function generateTurnId(): string { return randomUUID(); } — node:crypto.randomUUID() which is UUID v4 by spec (verified by turn-context.test.ts:30-36 regex ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$).
This satisfies brief §3 Task A1 "generation point: orchestrator turn entry, crypto.randomUUID() v4, ne v7, ne custom."
1.2 Propagation surface (verified grep + read)
| File | turnId role | Lines of interest |
|---|---|---|
packages/agent/src/turn-context.ts |
generator + logTurnEvent(turnId, payload) helper |
29, 45-51 |
packages/server/src/local/routes/chat.ts |
generation site (POST /api/chat entry) | 318-325 |
packages/agent/src/agent-loop.ts |
optional turnId?: string in config; logs agent-loop.enter / .exit / .tool.enter / .tool.exit |
75-80, 100, 103, 384, 477-484 |
packages/agent/src/orchestrator.ts |
optional turnId?: string in recallMemory opts; logs .enter/.exit + injection-block branch |
94-95, 589, 681, 702, 712 |
packages/agent/src/prompt-assembler.ts |
optional turnId?: string; logs prompt-assembler.assemble |
86-87, 414 |
packages/agent/src/combined-retrieval.ts |
optional turnId?: string in CombinedSearchOptions; logs retrieval.enter/.exit (+ KVARK branch) |
60-61, 215, 230-254 |
packages/agent/src/cognify.ts |
optional turnId?: string in cognify/cognifyFrame/cognifyBatch; logs enter/exit for each |
48-55, 106-139 |
packages/agent/src/index.ts |
barrel export of generateTurnId |
3 |
packages/server/src/benchmarks/aggregate.ts |
turnId carried in benchmark aggregate records | (via grep) |
The six target files from brief §3 are all covered; the list is 9 files total when counting the generator, barrel, benchmark aggregate, and server route.
1.3 Persistence format in chat.ts
Current behavior (chat.ts:324): turnId is generated and logged via logTurnEvent(turnId, { stage: 'chat.turn.start', ... }). Logs use the shared pino logger with turnId as a structured field. There is no dedicated per-turn trace-store row for chat turnId persistence — reconstruction is log-scrape (pino) + the test-only startTurnCapture() buffer from turn-context.ts:61-76.
The broader packages/core/src/mind/execution-traces.ts store exists (for the evolution subsystem) but is not currently keyed on turnId — it uses traceId + runId for evaluation datasets. This is intentional: trace-store was scoped to evolution evals, while turnId is the lightweight correlation key for one POST /api/chat cycle.
1.4 Tests already in place
packages/agent/tests/turn-context.test.ts — three describe blocks:
turn-context helpers(5 tests): UUID v4 shape, silent no-op when undefined, capture buffer semantics, concurrent-turn isolation, stopTurnCapture reverts mode.H-AUDIT-1 stage threading (end-to-end trace assertion)(1 test, line 80): simulates chat.ts → agent-loop → orchestrator.recallMemory → retrieval → prompt-assembler → tool-call → cognify → agent-loop.exit with a single turnId, assertsnew Set(buf.map(e => e.turnId)).size === 1. This is the "reconstruct full turn graph from a single turnId" acceptance test the brief asks for.H-AUDIT-1 source-tree regression guard(1 test, line 121): reads each of the six required files from disk and asserts every one containsturnId. Fails if any future edit accidentally drops trace plumbing.
1.5 Acceptance gate already satisfied
| Brief acceptance item | Current state | Evidence |
|---|---|---|
grep -n "turnId" packages/**/*.ts ≥ 6 hits |
≥50 hits across 9 files | Sprint 11 Day-1 grep output, §1.2 table |
| Unique files with match ≥ 5 | 9 files | §1.2 table |
| Unit test reconstructs full turn graph from single turnId | exists | turn-context.test.ts:80-117 |
| Zero regressions on existing suites | green pre-sprint (S4 handoff: 4974/4975) | will re-verify post-any-change in A2 |
tsc --noEmit clean |
green pre-sprint | same |
The A2 "turnId implementation" acceptance is met on HEAD. A2 becomes a narrow, reasoning_content-only task; see §2.
2. Net-new scope for Stage 2 on/64K — reasoning_content handling
Stage 2 batch runs on qwen3.6-35b-a3b-via-openrouter with thinking=on, max_tokens=64000 (LOCKED 2026-04-22, decision doc §1). Qwen3.6 with thinking enabled emits a reasoning_content field on the response object, separate from the finalized answer. Per decision doc §5, design must cover three rules: persistence, retention, exclusion.
2.1 Execution surface that sees reasoning_content
Stage 2 does not run the production chat stack (orchestrator + cognify + tools + prompt-assembler). It runs through benchmarks/harness/src/llm.ts → LiteLLM → OpenRouter → Qwen. The four cells in benchmarks/harness/src/cells.ts are pure-LLM prompts (no memory tool, no evolution wiring in the harness scaffold as of HEAD).
Consequence: reasoning_content capture lands in the harness layer, not the production chat stack. Production chat (which today doesn't ship with thinking=on by default on any route) gets the exclusion rule only; capture/persistence is explicitly out of this design doc's scope until a future brief.
2.2 Persistence rule
Harness layer (primary):
- Extend
LlmCallResult(inbenchmarks/harness/src/llm.ts:12-22) with an optionalreasoningContent?: stringfield. Populated only when the provider response includes it; leftundefinedotherwise to preserve back-compat for models that don't emit reasoning. - Parse from two canonical response shapes:
- DashScope-intl native:
body.choices?.[0]?.message?.reasoning_content(snake_case, top-level in message). - OpenRouter unified reasoning API:
body.choices?.[0]?.message?.reasoning(note: different key; OR's unified API normalizes cross-provider).
- DashScope-intl native:
- Fallback: if neither is present but response body has a top-level
body.reasoning_content(older DashScope shape), read that too. Log onereasoning_content_shape_unknownwarning if we see a response with thinking=on requested but no reasoning surface, so a future probe can catch provider schema drift. - Extend
JsonlRecord(inbenchmarks/harness/src/types.ts) with an optionalreasoning_content?: stringandreasoning_content_chars?: numberfield. The record is keyed byturnId(already there as the first field), so reconstruction from a single turnId reads the JSONL row and gets both answer and reasoning.
Production chat path (out-of-scope for A2, documented for future):
- If/when a production request is issued against a thinking-enabled route,
reasoning_contentMUST NOT be persisted to frames, memory, or KnowledgeGraph. It flows through the response and is discarded after the stream completes. If operator logging of reasoning is ever required, a separate design doc and opt-in flag will scope it. No silent write-through.
2.3 Retention policy
Harness JSONL artifacts:
- The JSONL file under
benchmarks/results/*.jsonlis the canonical persistence surface. Retention follows the existing benchmark artifact convention: committed to the repo when landing a benchmark report; otherwise lives inbenchmarks/results/gitignored until the sprint that produced it is closed and a curated subset (summary + representative rows) is moved topreflight-results/. Full reasoning_content is not moved intopreflight-results/— only a summary char-count aggregate, to keep report size manageable. - Raw JSONL with reasoning_content stays in
benchmarks/results/locally for the life of the sprint and is deleted/pruned when the sprint close-out report lands onorigin/main. No long-term reasoning_content archival. - Rotation: daily housekeeping is not automated in this design. If Stage 2 full-run produces ≥2GB of JSONL (estimated ceiling: ~1GB for 2000-call full-run at reasonable reasoning-token sizes), a manual prune step goes in the Sprint 12 close-out runbook. Not a Sprint 11 gate.
Logs (pino):
logTurnEvent(turnId, { stage: 'llm.response', reasoningChars: N })emits the character count only, not the content itself. This gives observability (did reasoning happen? how big?) without polluting logs with possibly-sensitive chain-of-thought.
2.4 Exclusion rule
Reasoning_content MUST NOT appear in:
- User-facing output streams — the SSE chat.ts path streams
textonly, not reasoning. (Already true; guarding against regressions when anyone wires a thinking-enabled route to production chat.) - Judge inputs —
benchmarks/harness/src/judge-runner.tspasses onlymodelAnswer(the final text) to the judge. The judge never sees reasoning_content. This preserves judge neutrality and prevents the judge from being biased by reasoning artifacts that aren't part of the model's final answer. - Public trace viewers / UI — any future chat-UI trace inspector must project JSONL records with reasoning_content stripped unless the caller has an explicit
includeReasoning: truepermission. v1 guidance: don't add a UI trace inspector at all; reasoning_content lives in CLI-accessible JSONL only for the benchmark team. - Frames / memory / KnowledgeGraph persistence — production chat never writes reasoning_content downstream of the LLM call.
- MCP response payloads — MCP tools return structured results; reasoning_content is not a tool-call product.
- Summary exports and aggregate reports —
benchmarks/harness/src/metrics.tsaggregates cost/latency/accuracy; it computes areasoning_content_charsaggregate (sum, p50, p95) but does not copy the content into summary JSON. The summary stays <100KB so it fits in briefs.
Permission surface for opt-in inclusion: the optional includeReasoning: boolean parameter lives on JSONL-reader utilities only (not on harness writers). Writers always write reasoning; readers default to stripping it. This puts the gate on the read path, which is where the visibility decision belongs.
2.5 Invariant
turnId is a foreign key — given a JSONL row with turnId T, a consumer can reconstruct:
- The cell/control + model + instance that generated the row (existing fields)
- The final model answer (
text/model_answer) - The judge verdict if judging was enabled (
judge_verdict+judge_rationale) - The reasoning trace that produced the answer (
reasoning_content, net-new)
The four of these together form the "full turn graph" for a Stage 2 batch turn. This is stronger than the production-chat graph (which today reconstructs via log events) because benchmark rows are structured JSONL by design.
3. Test scenario (sample code — not committed yet)
Two net-new test cases land in benchmarks/harness/tests/reasoning-capture.test.ts:
// Test 1: reasoning_content round-trip — harness llm.ts captures, JSONL row persists
it('captures reasoning_content when provider returns it and persists to JSONL under turnId', async () => {
const fakeLlm = createFakeLlmClient({
responseBody: {
choices: [{
message: {
content: 'Paris',
reasoning_content: 'The user asked for capital of France. France capital is Paris.',
},
}],
usage: { prompt_tokens: 10, completion_tokens: 2 },
},
});
const result = await fakeLlm.call({ model: stage2Model, systemPrompt: 'sys', userPrompt: 'q' });
expect(result.text).toBe('Paris');
expect(result.reasoningContent).toMatch(/capital of France/);
expect(result.reasoningContent?.length).toBeGreaterThan(0);
});
// Test 2: full turn-graph reconstruction from single turnId (JSONL round-trip)
it('reconstructs full turn graph (answer + reasoning + cost + latency) from single turnId', async () => {
// Run one harness turn with a fake LLM emitting reasoning_content.
// Read back the JSONL. Assert: filter by turnId yields exactly one row
// containing answer, reasoning_content, cost, latency, judge payload (if judged).
const turnId = await runOneHarnessTurn({ model: stage2Model, fakeLlm });
const rows = readJsonl(outputPath).filter(r => r.turnId === turnId);
expect(rows).toHaveLength(1);
expect(rows[0].model_answer).toBeDefined();
expect(rows[0].reasoning_content).toBeDefined();
expect(rows[0].reasoning_content_chars).toBe(rows[0].reasoning_content!.length);
});
Non-goals for these tests: real API calls (smoke test for that lives in B1 apply), full-run timing (Stage 2 C3 covers that), judge-reasoning interaction (§2.4 exclusion rule covers by construction).
4. Acceptance criteria (updated against current state)
| # | Criterion | Status |
|---|---|---|
| 1 | grep -n "turnId" packages/**/*.ts returns ≥6 hits |
✅ already met (≥50 hits, 9 files) |
| 2 | Unique files with match ≥5 | ✅ already met (9 files) |
| 3 | Unit test reconstructs full turn graph from single turnId (production stack) | ✅ already met (turn-context.test.ts:80-117) |
| 4 | Unit test reconstructs full turn graph including reasoning_content (harness layer) | ⬜ A2 net-new — benchmarks/harness/tests/reasoning-capture.test.ts §3 |
| 5 | LlmCallResult + JsonlRecord extended with reasoningContent / reasoning_content |
⬜ A2 net-new |
| 6 | Harness captures reasoning_content from Qwen (two response shapes supported) | ⬜ A2 net-new |
| 7 | Reasoning_content never written to frames/memory/KG/UI/summary reports | ⬜ A2 net-new (assert via inspection + regression guard test) |
| 8 | pnpm test passes with zero regressions |
⬜ A2 gate |
| 9 | tsc --noEmit clean on touched packages |
⬜ A2 gate |
| 10 | Commit message: feat(audit): H-AUDIT-1 reasoning_content capture per design doc 2026-04-22 |
⬜ A2 gate |
5. Open questions for PM ratification
- Confirm narrowed A2 scope. Does PM accept that A2 implementation ships reasoning_content handling only, given that production-chat turnId propagation is already landed? If yes, exit ping filename is
sessions/2026-04-22-sprint-11-h-audit-1-exit.mdwith the §4 criteria 4–7 closed. - Memory note correction. Authorize marking
project_h_audit_1_not_implemented.mdas superseded by this design doc in the memory index. The note was accurate at write time; Sprint 10 landed the plumbing. The correction prevents future sessions from re-doing completed work. - Cross-cutting note on
reasoning/reasoning_contentparser. OpenRouter's unified reasoning API uses keyreasoningwhile DashScope native usesreasoning_content. The harness must parse both shapes. If PM prefers exclusive OR (one or the other, not both), flag here so CC-1 picks. - Persistence slot under turnId. Proposed: same JSONL row, net-new field
reasoning_content. Alternative considered: separate*.reasoning.jsonlsibling file to keep the main JSONL compact. Stuck with same-row for simplicity unless PM prefers separation. - Retention beyond sprint. Current proposal is to keep raw reasoning_content JSONL local only, delete at sprint close. PM may want a long-term audit archive (compressed
.jsonl.gzunderbenchmarks/archive/) for reproducibility of Stage 2 full-run — decision doc §5 retention rule implies this. Flagged for ratification.
6. Implementation plan for A2 (after ratification)
Surgical, non-speculative:
- Add
reasoningContent?: stringtoLlmCallResultinbenchmarks/harness/src/llm.ts. Parse frommessage.reasoning_contentORmessage.reasoningOR top-levelbody.reasoning_content(in that order). Log onereasoning_content_shape_unknownwarning on miss when thinking was requested. - Add
reasoning_content?: string+reasoning_content_chars?: numbertoJsonlRecordinbenchmarks/harness/src/types.ts. Populate inrunner.tsfromresult.reasoningContent. - Update
metrics.tsaggregate to computereasoningContentChars: { sum, p50, p95 }when any row has it. - Ensure
judge-runner.tsdoes not pass reasoning_content to the judge (verify; no change expected per current code). - Land two new tests per §3 in
benchmarks/harness/tests/reasoning-capture.test.ts. pnpm test+tsc --noEmit --project benchmarks/harness/tsconfig.json.- Exit ping:
sessions/2026-04-22-sprint-11-h-audit-1-exit.mdwith grep output + test log + commit SHA.
Budget: $0 for unit tests (fake LLM client). Only real API cost is Task B1 smoke test, already accounted in that task's $0.05 cap. Wall-clock estimate: 2-3h for the net-new slice (the big slice was landed in a prior sprint).
7. Anti-patterns
- Do not re-implement turnId generator. Use
generateTurnId()from@waggle/agent. The harness already imports it inrunner.ts:32. - Do not thread turnId through cells. Cells are pure prompt-assembly; turnId is passed as a parameter (already) but only the runner needs to emit it into JSONL. Cells don't log.
- Do not persist reasoning_content to production memory. The §2.4 exclusion rule is a hard contract. If a future task wants this, it needs a separate design doc and PM lock.
- Do not add reasoning_content to judge input. §2.4 rule (2). Breaking this invalidates the judge-methodology axis that cleared Sprint 11 gate (Fleiss' κ=0.8784 on answer-only input).
- Do not broaden scope beyond §6. A2 is reasoning_content only. Anything else (tool-call schema extensions, MCP bridge, production thinking-on wiring) is a separate ticket.
8. Related
briefs/2026-04-22-cc-sprint-11-kickoff.md§3 Track A A1 + A2decisions/2026-04-22-stage-2-primary-config-locked.md§5 (reasoning_content handling extension)decisions/2026-04-22-sprint-11-scope-locked.md§4.1–4.2 (gate criteria)packages/agent/src/turn-context.ts— generator + logging helperspackages/agent/tests/turn-context.test.ts— existing regression guards (6-file grep, full turn-graph reconstruction)packages/server/src/local/routes/chat.ts:318-325— generation sitebenchmarks/harness/src/llm.ts— A2 primary edit targetbenchmarks/harness/src/runner.ts:32— turnId already imported + threadedbenchmarks/harness/src/types.ts—JsonlRecordextension target
End of A1 design doc. Awaiting PM ratification on §5 open questions before CC-1 moves to A2 implementation.