06-ai-tucanwave: W3filled23 citations

TUCAN RAG Tools — Tool Discovery and Filtering

Audiences: developer, internal

TUCAN RAG Tools — Tool Discovery and Filtering

Tool-RAG is human-discovery infrastructure — a Qdrant collection of tool embeddings used by the studio / inventory tooling to find a tool by intent — NOT a per-turn semantic filter at LLM call time. Production agents receive a static, category-filtered tool list at new Agent({...}) construction time.

Business Purpose

There are ~350 createTucanTool definitions across ~35 categories in ddx-api. A single agent (e.g. the clinical specialist) is bound to dozens of them. Two engineering problems fall out:

  1. For humans authoring agents and writing prompts: "is there already a tool for X?" must be answerable in seconds. Tool-RAG provides a searchRelevantTools(prompt, opts) API that returns the top-K most semantically relevant tool IDs, with scores, against a plain-language query.
  2. For per-agent prompt-shaping: even with a static category allow-list, the prompt that tells the LLM "here are your tools and when to use each" needs to be ordered by descending relevance to the current intent. The tool-rag-prompt.builder consumes Tool-RAG search results to rank the bound tools in the agent system prompt.

Tool-RAG is not the dispatcher's tool selector. The dispatcher's selector is deterministic: it reads agentType from the session, fetches the registry's category allow-list, and binds Mastra tools at construction time. Re-running a vector search per turn would add latency without changing the bound set — see Operational Notes for the dormant "SkillSearchProcessor" pitfall.

Audiences

  • Investor: Tool-RAG keeps the catalog scalable. Doubling the catalog does not double the LLM prompt size — irrelevant tools are filtered out by category at agent construction and ranked by embedding similarity inside the bound set.
  • Developer / partner: Adding a tool with good ragDescription and ragKeywords makes it discoverable to TUCAN's authoring + studio tooling automatically — there is no manual "register me in the prompt" step. The Qdrant collection lives at tucan_tool_rag_<sha-prefix> and is rebuilt incrementally when tool metadata changes.
  • Internal (ops / support): A regression test asserts the embedding text composition is {id}\n{category}\n{ragDescription}\nKeywords:{keywords} ([[ddx-mastra-specialist_memory.md]] commit f6eef8a18). Any change there is a breaking-bit — caches and embeddings must be invalidated.

Architecture

diagram
┌──────────────────────────────────────────────────────────────┐
│  Tool authoring                                              │
│   createTucanTool({                                          │
│     metadata: {                                              │
│       ragDescription: 'USE WHEN ... DO NOT USE WHEN ...',    │
│       ragKeywords: ['en kw1', 'de kw1', ...],                │
│     }                                                        │
│   })                                                         │
└──────────────────────────────────────────────────────────────┘
                          │
                          ▼  (onModuleInit)
┌──────────────────────────────────────────────────────────────┐
│  ToolRagService                                              │
│   · onModuleInit → ensureCollection (3 retries: 500/1500/4500ms)│
│   · indexToolsIfChanged(tools[]) — content-hash skip          │
│   · for each changed tool:                                   │
│       text = buildToolEmbeddingText(tool)                    │
│       vec  = EmbeddingService.generateEmbedding(text)        │
│       pid  = toolIdToPointId(slug)  // uuid v5                │
│       upsertPoint(collection, pid, vec, payload)             │
└──────────────────────────────────────────────────────────────┘
                          │
                          ▼  (consumer calls)
┌──────────────────────────────────────────────────────────────┐
│  searchRelevantTools(prompt, { limit, scoreThreshold, ... }) │
│   · embed(prompt) → query vector                              │
│   · qdrantRepo.search(collection, queryVec, opts)             │
│   · returns [{ toolId, score }, ...] sorted desc              │
└──────────────────────────────────────────────────────────────┘
                          │
                          ▼  (build-time, NOT per-turn)
┌──────────────────────────────────────────────────────────────┐
│  tool-rag-prompt.builder.ts                                  │
│   · re-orders the agent's BOUND tool list by tool-RAG score │
│   · clinical agent stays clinical — category filter wins    │
│   · ordering is for prompt readability, not access control  │
└──────────────────────────────────────────────────────────────┘

buildToolEmbeddingText is the contract surface that downstream embeddings depend on. It is snapshot-tested (tool-rag/__tests__/) and must not change without a re-index.

Tech Stack & Choices

ComponentChoiceWhy
Vector storeQdrant at port 15333 (shared with project assets)Already an infrastructure dependency; HTTP API; cosine + payload filter.
Embedding modeldudoxx-embed via https://embedder.home.dudoxx.com/v1/embeddings, dim 2560Same production CUDA endpoint that powers the DDX RAG sidecar; one embedding space across docs, code, tools.
Point IDuuidv5(toolSlug, TOOL_RAG_NAMESPACE) (tool-rag.service.ts:30)Idempotent upserts — re-indexing the same slug overwrites, never produces orphans.
Collection nametucan_tool_rag_<sha> (versioned by embedding text hash)Schema breaking changes get a new collection; rollback = config flip.
Change detectioncontent-hash on the composed embedding text"indexToolsIfChanged" is a no-op when nothing changed; cheap restart.
Module initOnModuleInit triggers ensureCollection with retryDon't fail boot if Qdrant is briefly unavailable; defer to first index call.
Sibling-disambiguationanti-synonym keywords ("NOT encounter", "NOT fhir-encounter") + canonical TOOL_ID suffixPitfall f6eef8a18 — RAG was surfacing Visit tools for FHIR Encounter queries until anti-synonyms were added.
Static per-agent bindingAgentRegistryService binds tools by category, not by tool-RAGRemoves the per-turn embedding call from the hot path; tool access control is deterministic, not statistical.
Multi-entity intake heuristicMULTI_ENTITY_PATTERNS regex set + detectMultiEntityIntakeWhen the prompt mentions ≥2 entities ("patient and visit and intake"), Tool-RAG widens its return set so the planner can fan out.

Alternatives rejected:

  • Per-turn semantic tool reduction (the "SkillSearchProcessor" pattern) — wired but DORMANT in the registry config ([[ddx-mastra-specialist_memory.md]]). Switching it on would mean a Qdrant round-trip per turn for marginal context savings on a category-filtered set of ~30 tools, and the LLM cost saving rounds to zero against dudoxx-gemma internal pricing.
  • Local FAISS index in-process — adds a deploy artifact and a refresh story. Qdrant is already deployed.
  • No embedding at all (keyword search) — multi-lingual (German-first) clinical vocabulary is sibling-rich; keyword search picks Visit for Encounter queries unless we maintain a giant synonym table.

Data Flow

Business outcome: A developer types "search for a tool to calculate BMI" into the TUCAN studio. The studio returns utility/calculate-bmi at the top, scored 0.84.

Technical mechanism:

  1. Studio calls ToolRagService.searchRelevantTools('search for a tool to calculate BMI', { limit: 5 }) (ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:458).
  2. Service calls EmbeddingService.generateEmbedding(prompt) → HTTPS to embedder.home.dudoxx.com/v1/embeddingsdudoxx-embed model, dim 2560.
  3. Service calls QdrantRepository.search(collection, queryVector, { limit, scoreThreshold }) (ddx-api/src/ai/tucan/tool-rag/qdrant.repository.ts:183).
  4. Qdrant returns ordered [{id, score, payload}, ...].
  5. Service converts point IDs back to tool slugs via the payload.toolId field.
  6. Studio renders the ranked list with rag-description preview.

Build-time rerank for the clinical agent:

  1. tool-rag-prompt.builder is called with the bound tool list (clinical agent: medications + prescriptions + clinical-notes + fhir + ui).
  2. For each tool the builder calls searchRelevantTools(tool's intent label) and orders the prompt section by descending score.
  3. The result is part of the agent's system prompt — produced once at agent registration, not per turn.

Implicated Code

  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:26TOOL_RAG_NAMESPACE UUID v5 namespace for stable point IDs.
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:30toolIdToPointId(toolSlug) — uuid v5 mapping.
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:80MULTI_ENTITY_PATTERNS regex set for fan-out heuristic.
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:100detectMultiEntityIntake(prompt) returns count.
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:124buildToolEmbeddingText(tool) — frozen contract (snapshot test).
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:146 — class ToolRagService implements OnModuleInit.
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:193onModuleInit() with 3 retries (500/1500/4500ms).
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:243indexToolsIfChanged(tools, opts) — content-hash skip path.
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:276raw = buildToolEmbeddingText(t) — composition site.
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:408qdrantRepo.upsertPoints(collectionName, points) — batch upsert.
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:458searchRelevantTools(prompt, options).
  • ddx-api/src/ai/tucan/tool-rag/tool-rag.service.ts:537 — multi-entity widening branch.
  • ddx-api/src/ai/tucan/tool-rag/qdrant.repository.ts:44 — class QdrantRepository implements IVectorStore.
  • ddx-api/src/ai/tucan/tool-rag/qdrant.repository.ts:111ensureCollection(name, vectorSize).
  • ddx-api/src/ai/tucan/tool-rag/qdrant.repository.ts:164upsertPoints(...).
  • ddx-api/src/ai/tucan/tool-rag/qdrant.repository.ts:183search(name, vector, options).
  • ddx-api/src/ai/tucan/tool-rag/embedding.service.ts:35EmbeddingService implements IEmbeddingService.
  • ddx-api/src/ai/tucan/tool-rag/embedding.service.ts:68generateEmbeddings(texts: string[]).
  • ddx-api/src/ai/tucan/tool-rag/embedding.service.ts:103generateEmbedding(text: string).
  • ddx-api/src/ai/tucan/registry/tool-rag-prompt.builder.ts — build-time per-agent reranking consumer.

Operational Notes

  • Tool-RAG is build-time, NOT per-turn. Project memory invariant ([[ddx-mastra-specialist_memory.md]], commit f6eef8a18): "agents receive static category-filtered lists at Agent() construction — NO per-turn semantic reduction." Do not wire Tool-RAG into the dispatch hot path.
  • SkillSearchProcessor / ToolSearchProcessor is NOT wired (memory shard, f6eef8a18). The config exists but is dormant; do not re-document it as "active" without re-verifying.
  • Embedding text composition is frozen. buildToolEmbeddingText returns {id}\n{category}\n{ragDescription}\nKeywords:{keywords}. Any change requires a full re-index — there is a snapshot test that will fail loudly.
  • Sibling disambiguation needs anti-synonyms. Two tools with near-identical ragDescription + ragKeywords produce near-identical embeddings; Tool-RAG picks the wrong one. Add "NOT encounter" / "NOT fhir-encounter" + a canonical TOOL_ID suffix.
  • ensureCollection retries 3× then no-ops. If Qdrant is down at boot, the service logs a warning and continues — the collection will be created on the first indexToolsIfChanged call. Do not treat a startup log line as fatal.
  • uuidv5(slug, NAMESPACE) is the only legitimate point ID generator. Inserting points with random UUIDs creates orphans on re-index.
  • Multi-entity intake widens the return set. A prompt mentioning ≥2 entities ("patient and intake form") triggers detectMultiEntityIntake(prompt) >= 2 (tool-rag.service.ts:537), used by the orchestrator's planner to fan out across more tools.
  • Per-tool authoring rule (from mastra review checklist): every tool MUST have metadata.ragDescription AND metadata.ragKeywords in EN + DE minimum. Tools missing these are surfaced as Critical by the review agent and degrade Tool-RAG quality.
  • Document/knowledge RAG is a different service. Tool-RAG embeds tool metadata; the user-facing document RAG (clinic uploads, patient documents, knowledge base) lives in ddx-api/src/ai/tucan/tools/rag/ + tools/knowledge/ + tools/deep-search/ and is covered by [[rag-system-indexation]].
  • [[tucan-assistant]] — dispatcher orchestration entry.
  • [[ai-medical-tools]] — the tool catalog Tool-RAG indexes.
  • [[tucan-intent-filtering]] — the intent classifier that runs BEFORE per-agent binding (and is the production filter for agent + tool scope, not Tool-RAG).
  • [[tucan-dispatcher]] — confirms the no-per-turn-RAG contract.
  • [[agent-templates]] — agent factory whose system prompt consumes tool-rag-prompt.builder.
  • [[rag-system-indexation]] — the user-facing document/knowledge RAG pipeline (different Qdrant collections, different lifecycle).
  • [[sse-event-engine]] (W1) — emits metrics:toolrag events that surface tool-rag activity in the SSE stream.
  • docs/claude-context/CLAUDE_TUCAN_API_INJECTION.md — tool-RAG filtering pattern reference.
    TUCAN RAG Tools — Tool Discovery and Filtering — Dudoxx Docs | Dudoxx