Phoenix Agent — Knowledge Base Mastra Agent with Hidden-Plan Retrieval
Audiences: developer, internal, partner
Phoenix is the Mastra agent that powers Ask Phoenix. It grounds every answer in the
dudoxx_kb_techdocsQdrant corpus via a two-tool plan-then-act protocol: a hiddenkb-plandecomposition turn followed bykb-searchretrieval calls — one per sub-question. The plan is private to the agent; the user only ever sees a "Planning…" chip and the final grounded answer.
Business Purpose
Investors, doctors, and partners asking about the Dudoxx HMS need a trustworthy assistant that:
- Never fabricates — every claim must trace back to a corpus chunk
(
[layer/slug]citation chip rendered as a clickable deep-link). - Handles multi-aspect questions — a question like "Compare ddx-api authentication vs FHIR partition isolation" requires retrieving from distinct sub-topics, not a single search.
- Refuses out-of-corpus questions politely (pricing for external products, personal advice, generic coding help) without hallucinating.
Phoenix solves the first two with the hidden-plan turn; the third with an explicit refusal protocol baked into the system prompt. It is the second tier of the Phoenix KB system — Tier 1 (CR-038) hardened output discipline; Tier 2 (CR-039) restored multi-search recall after Tier 1 inadvertently regressed it.
Audiences
- Investor: Phoenix is the public-facing demonstration that the Dudoxx HMS knowledge base is real, queryable, and grounded — not just static documentation. The same Mastra runtime that powers internal TUCAN agents serves the public Ask Phoenix surface, on a single shared LLM gateway.
- Developer / partner: To extend Phoenix's corpus, ingest new chunks into
dudoxx_kb_techdocs(Qdrant collection name inddx-web/src/mastra/tools/kb-search.ts:94). To extend its behaviour, editPHOENIX_SYSTEM_PROMPTinddx-web/src/mastra/agents/phoenix.ts:17— preserve the Output-language-strict and Off-corpus-refusal sections verbatim, those are Tier 1 invariants that carry forward. - Internal (ops / support): Phoenix has falsifiable acceptance gates
measured by the 20-question eval harness (
scripts/kb-phoenix-eval.ts). Any prompt change must pass AC-1 multi-search rate ≥ 10/20, AC-10 length non-inflation ≤ +20 chars, AC-22 output-language strictness, before shipping. See [[phoenix-kb-eval-harness]].
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Public surface │
│ /[locale]/docs/ask (PhoenixFullPageChat) │
│ AskPhoenixPanel side-panel on any /docs/[layer]/[topic] │
└─────────────────────────────────────────────────────────────┘
│ AI SDK v6 useChat → /api/phoenix/chat (POST)
▼
┌─────────────────────────────────────────────────────────────┐
│ ddx-web/src/app/api/phoenix/chat/route.ts:21 │
│ · validates messages, builds DudoxxEnv │
│ · calls streamPhoenix(opts) → Response.body (SSE stream) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ streamPhoenix (phoenix.ts:84) │
│ · model: dudoxxMastraModel(env, 'dudoxx-gemma') ← Tier 1 │
│ · system: PHOENIX_SYSTEM_PROMPT │
│ · tools: buildPhoenixTools(env) │
│ · temperature: 0.3, enable_thinking: false │
│ · stopWhen: steps.length >= 8 │
└─────────────────────────────────────────────────────────────┘
│ ▲
▼ │
┌──────────────────────────────────┐ ┌────────┴────────┐
│ buildPhoenixTools (phoenix.ts:62)│ │ Closure-scoped │
│ const kbPlanTurnState = { │◄──┤ per-turn state │
│ prevCalls: [] │ │ (T-09 guard) │
│ }; │ └─────────────────┘
│ return { │
│ 'kb-plan': tool({ │
│ execute: input => │
│ runKbPlan(input, │
│ kbPlanTurnState) │
│ }), │
│ 'kb-search': tool({ │
│ execute: input => │
│ runKbSearch(input, env) │
│ }) │
│ } │
└──────────────────────────────────┘
The plan-then-act protocol (system prompt)
1. On every in-scope question, MUST call kb-plan FIRST with 1-4 sub-questions. 2. After kb-plan ok:true with N sub-questions, MUST call kb-search exactly N times — one per sub_question — BEFORE writing any reply text. (If N=1 run one; N=2 run two; N=3 run three; N=4 run four.) 3. NEVER call kb-plan twice for the same question. After ok:true the next tool call MUST be kb-search. On ok:false, retry kb-plan EXACTLY ONCE using its suggestion, then succeed or fall back to a single kb-search. 4. kb-plan is private — NEVER mention plan / sub-questions / tool itself in the reply.
Refusal-class questions (out-of-scope, vendor pricing, generic coding requests) skip kb-plan entirely and apply the refusal protocol directly.
The tool-layer anti-loop guard (T-09, CR-039)
The "max 2 kb-plan calls per turn" rule is enforced deterministically at the tool layer, not just by the prompt:
kb-planaccepts an optionalturnState: { prevCalls: [] }(closure-scoped insidebuildPhoenixTools).- On the 2nd call: if
prevCalls.length >= 2OR Jaccard similarity ≥ 0.7 between current sub_questions and previous ones (lowercased + stoplisted token sets), kb-plan short-circuits with{ ok: false, suggestion: 'Plan already produced — call kb-search using the existing sub_questions, do not retry kb-plan.' }. - The model sees ok:false, follows the prompt's existing "retry on ok:false with suggestion" rule, and the suggestion redirects to kb-search.
This pattern fixes a class of failure where dudoxx-gemma at temperature 0.3 ignored the prompt-level "max 2 calls" rule on single-identifier lookups (Q6 "port 6100", Q8 "LiteLLM master key"), looping kb-plan with synonymed sub_questions until the step budget exhausted and the answer came back empty. See [[phoenix-kb-eval-harness]] for the eval trajectory.
Tech Stack & Choices
| Choice | Value | Rationale |
|---|---|---|
| Mastra agent | streamText from ai SDK v6 | Same runtime as TUCAN agents — one Mastra surface for internal + public AI |
| Model | dudoxx-gemma (Google Gemma family) | Tier 1 invariant — enable_thinking: false, temperature 0.3. NOT Qwen — common misconception |
| Vector store | Qdrant dudoxx_kb_techdocs collection | Sibling to tucan_tool_rag_* (Tool-RAG) but with curated technical-docs payloads |
| Embedding | dudoxx-embed (dim 2560) via Dudoxx LLM gateway | Same embedder as the rest of the platform — no per-surface variance |
| Plan validator | Pure heuristic (no LLM) in decompose.ts | AC-9 invariant. Rejects: empty array, identical-to-root, multi-entity-with-1-subQ, sub-questions <4 chars |
| Anti-loop guard | Closure-scoped state + Jaccard 0.7 | Determinstic; concurrent-request-safe by Mastra's per-call factory; no global state |
Why hidden plan over visible plan?
Tier 1 (CR-038) suppressed a visible 5-step PLAN/SEARCH/JUDGE/SYNTHESIZE/SELF-CHECK
scaffold to clean up output discipline. Side effect: multi-search recall
regressed from 7/20 → 3/20 because the visible PLAN line had been
autoregressively conditioning the model into multi-search behaviour. Tier 2
restores the conditioning via a structured tool call (kb-plan with typed
sub_questions[]) whose existence in the model's context window primes
multi-search WITHOUT leaking the plan to the user.
Data Flow
User typed message
│ (Next.js client → /api/phoenix/chat POST)
▼
ddx-web/src/app/api/phoenix/chat/route.ts:21
│ messages: ModelMessage[]
▼
streamPhoenix({ messages, env }) ← phoenix.ts:84
│ AI SDK streamText, max 8 steps
▼
Step 1 — tool call: kb-plan
{ question, sub_questions: ['...', '...'] }
→ runKbPlan(input, kbPlanTurnState) ← kb-plan.ts:106
· validateDecomposition (heuristic, no LLM)
· push to turnState.prevCalls
→ { ok: true, sub_questions: trimmed }
│
▼
Step 2..N — tool calls: kb-search × N
{ query: sub_question[i], topK }
→ runKbSearch(input, env) ← kb-search.ts:199
· embed query (dudoxx-embed)
· Qdrant vector + payload-filter search
→ top chunks { layer, slug, heading, excerpt, score }
│
▼
Step N+1 — text-delta stream
· 1-sentence content opener
· ≤5 bullets, each with [layer/slug] citation chip
· stream tokens → SSE → useChat → PhoenixAnswer (renders markdown)
Per-question telemetry (used by the eval harness, NOT in production):
plan_sub_question_count=tool_calls[0].args.sub_questions.lengthnum_searches= count oftool_callswherename === 'kb-search'top_score= max score across all kb-search hitsassistant_text= concatenated text-delta chunks (UI ALSO sees these)
Implicated Code
Mandatory ≥3 file:line citations.
ddx-web/src/mastra/agents/phoenix.ts:17—PHOENIX_SYSTEM_PROMPTtemplate literal: Output-language-strict (line 27), Plan-then-act protocol (line 30), Forbidden list (line 43), Off-corpus refusal (line 53).ddx-web/src/mastra/agents/phoenix.ts:62—buildPhoenixTools(env)declareskbPlanTurnStateclosure-scoped at line 63, wiresrunKbPlanrunKbSearchvia AI SDKtool({...})factory, returns{ 'kb-plan', 'kb-search' }(kebab tool ids = matched by the model's tool calls).
ddx-web/src/mastra/agents/phoenix.ts:84—streamPhoenix(opts)is the AI SDKstreamTextcall; Tier 1 invariants at lines 87/91/94 (dudoxx-gemma,temperature: 0.3,enable_thinking: false).ddx-web/src/mastra/tools/kb-plan.ts:106—runKbPlan(input, turnState?): short-circuits with ok:false whenprevCalls.length >= MAX_PLAN_CALLS_PER_TURNorjaccardSimilarity(...) >= JACCARD_THRESHOLD(=0.7). Otherwise callsvalidateDecompositionand pushes toturnState.prevCalls.ddx-web/src/mastra/tools/kb-plan.ts:90—jaccardSimilarity(a, b): intersection-over-union on lowercased + stoplist-filtered tokens. Returns 0 if union empty.ddx-web/src/mastra/lib/decompose.ts:30—export const STOPLIST: ReadonlySet<string>used by bothvalidateDecompositionandkb-plan.ts:jaccardSimilarity.ddx-web/src/mastra/lib/decompose.ts:76—validateDecomposition(input): 4 reject branches →{ ok: false, suggestion }. AC-9 pure heuristic (zero LLM imports).ddx-web/src/app/api/phoenix/chat/route.ts:21—POSThandler validates the request, builds env, returns the SSE stream fromstreamPhoenix.
Operational Notes
Hard invariants (carry-forward from Tier 1 + Tier 2)
| Invariant | Where | Why |
|---|---|---|
model: dudoxx-gemma | phoenix.ts:87 | dudoxx-gemma is Google Gemma (NOT Qwen). Other Dudoxx LLMs have different tool-routing biases |
temperature: 0.3 | phoenix.ts:91 | Low temp keeps tool routing deterministic. Higher temp breaks the 4-rule protocol |
enable_thinking: false | phoenix.ts:94 | Suppresses Gemma's <think> traces — visible reasoning leaks the hidden plan |
| Output-language strict | phoenix.ts:27 | EN/DE/FR only; CJK/Cyrillic/Arabic suppressed mid-generation. Verified by eval cjk_leak=0/20 |
| Off-corpus refusal | phoenix.ts:53 | Refusal-class questions skip kb-plan AND kb-search. Refusal is the entire visible message |
| No visible PLAN | (absent from prompt) | The whole point of Tier 2 — hidden plan turn only |
| Max 2 kb-plan calls/turn | Tool-layer (kb-plan.ts) + prompt | Prompt rule is unreliable at temp 0.3 — tool-layer guard is the load-bearing enforcement |
| kb-plan zero side effects | kb-plan.ts | No Qdrant, no Prisma, no Redis. Only validates + tracks prevCalls. AC-9 enforces |
Pitfalls
- Spec text vs codebase pattern:
kb-search.tsexports schemas +runKbSearchand thetool({})wrap happens inphoenix.ts:buildPhoenixTools— there is NOcreateToolfactory in either tool file. New tools must mirror this shape, not "createTool". - Anti-synonym discipline:
kb-plantool description MUST avoid "retrieve", "search", "find", "lookup" — those keywords belong to kb-search. Routing confusion at temp 0.3 is the failure mode. - Forbidden tokens in system prompt: words like PLAN / SEARCH / JUDGE / SYNTHESIZE / SELF-CHECK MUST NOT appear as line-leading markers in the system prompt — dudoxx-gemma will echo them back into the user-visible output. Keep them inline within sentences.
- Per-turn state must be closure-scoped: a module-level
let prevCalls = []would corrupt across concurrent users.buildPhoenixTools(env)is invoked fresh perstreamPhoenix(opts)call — declare state inside that function. - Sub_questions cap = 4: Tier 2 spec caps decomposition at 4. The eval
stopWhen: steps.length >= 8budgets exactly 1 kb-plan + up to 4 kb-search + answer + buffer. Raising the cap requires re-budgetingstopWhen.
Validation
cd ddx-web pnpm tsc --noEmit pnpm eslint src/mastra/lib/decompose.ts src/mastra/tools/kb-plan.ts src/mastra/agents/phoenix.ts # Anti-LLM grep (AC-9): rg -n '(openai|anthropic|dudoxx-provider|@mastra/core/llm|qdrant|prisma)' src/mastra/tools/kb-plan.ts src/mastra/lib/decompose.ts | (! grep -q .) # Falsification eval (multi-minute): pnpm tsx scripts/kb-phoenix-eval.ts
Common defects
- Empty answer + 6+ kb-plan calls → tool-layer anti-loop guard not wired
(check
kbPlanTurnStatedeclared inbuildPhoenixToolsand passed torunKbPlan). - Multi-search rate ≤ 5/20 in the eval → system prompt rule 2 is missing the imperative N-times wording. Soft instructions don't carry at temp 0.3.
- CJK tokens in output →
enable_thinkingflipped to true, or Output-language-strict section truncated in a prompt edit. - Citation chips render as plain text →
PhoenixAnswermarkdown pipeline broken or[layer/slug]payload missinglayerfield (kb-search filter onlayervalue must use the raw slug, e.g.infrastructure, NOT01-infrastructure).