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
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)
| Option | Value |
|---|---|
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)
const kbPlanTurnState = {
prevCalls: []
};
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
| Step | Input | Callee | Actions |
|---|---|---|---|
Step 1 — kb-plan | { question, sub_questions: ['...', '...'] } | runKbPlan(input, kbPlanTurnState) ← kb-plan.ts:106 | validateDecomposition (heuristic, no LLM) · push to turnState.prevCalls |
Step 2..N — kb-search × N | { query: sub_question[i], topK } | runKbSearch(input, env) ← kb-search.ts:199 | embed query (dudoxx-embed) · Qdrant vector + payload-filter search |
| 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).