13-ai-condorwave: W6filled17 citations

Condor RAG Tool Search — Per-Turn Candidate-Set Resolution

Audiences: developer, internal

Condor RAG Tool Search — Per-Turn Candidate-Set Resolution

This is the Condor half of a two-engine story. TUCAN's Tool-RAG is build-time discovery infrastructure — agents get a static category-filtered tool list at new Agent({...}) time (see tucan-rag-tools). Condor inverts that: every turn resolves a fresh, bounded candidate set via cosine search, and that set is the ONLY tool surface the planner prompt is allowed to see. The full registry never reaches the model.

Business Purpose

The Condor tool registry is large (~108 business tools in the live clinical profile). Rendering all of them into the planner prompt cost 63.4 KB — the defect the candidate-set feature exists to fix. Three consequences fall out of solving it:

  1. Prompt budget. A bounded 6–12-tool manifest set replaces a full-registry dump, so the planner prompt stays under the hard byte budget even as the catalog grows.
  2. Selection quality. dudoxx-gemma picks worse from a longer list. A domain-scoped, deduped, relevance-ranked set of ~12 improves plan validity more than any prompt-wording change.
  3. Auditability. Every surfaced tool id carries a machine-readable activationReason — the run report can answer "why was this tool visible?" for any turn.

The trade-off this creates: a tool absent from the candidate set can never be picked. That is the single most common Condor mis-routing cause (a "book an appointment" turn mis-routed to visit.manage because appointment.manage was never retrieved) — and it is why the floors below exist.

Architecture

Rendering diagram…

resolveCandidateSet — the authoritative retrieval

condor-tool-rag.service.ts:556 is the one planner-phase retrieval per turn. It is deliberately not called twice: the plan phase used to re-call it, producing a same-turn cache hit that disclosed the identical set twice (workflow-runner.plan.ts:914 documents the removal).

StepBehaviorSite
1. Prompt hashsha256(trim(prompt)), first 16 hex chars:561
2. Same-turn cacheSession entry with a matching promptHash returns verbatim — one embed call feeds both prompt assembly and the "Recommended tools" block:562
3. Budgeted searchk = CANDIDATE_BUDGET_BY_PHASE.planner.max (12), scopeByDomain: true, alwaysInclude: floorToolIds:566, :572
4. Failure captureAny throw → hits = undefined, WARN log, never rethrown:581
5. Pure decisiondecideCandidateResolution({ragHits, priorAuditedSet, floorToolIds, budgetMax}):589
6. Cache writeOnly when source === 'rag' — a degraded turn must not overwrite the last good set. Bounded FIFO at 500 sessions:596
7. Audit logsource, size, budgetMax, full toolIds, activationReasons:608

Phase budgets are not one global top-K (candidate-resolution.ts:32): planner 6–12 manifests · tool-input generation 1 full contract · agent-call 3–8 · mutation recovery prior set + ≤3 new · formatter 0 mutation tools.

search() (:629) embeds via dudoxx-embed and queries the QDRANT_COLLECTIONS.condorTools collection with cosine distance, then postProcess (:773) applies, in order:

StageRuleConstant / site
Short-prompt fast path< 3 chars → []MIN_PROMPT_LENGTH, :638
Over-queryMetadata-aware calls request min(k*3, 50) so dedup + filters don't starve top-K:662
Workflow boost+0.15, or +0.25 on multi-entity intake, gated by applyWorkflowBoost(intent, prompt):83-84, :157
Domain penaltyOff-domain candidates lose 0.18soft demotion, never a hard exclude; generic is always exempttool-domain-scoping.ts:181, :191
Priority tiebreakEqual scores → higher priority wins; workflows get a floor of 75, others default 50:85-86, :170
Semantic-group dedupKeep all workflows + ungrouped; per `${semanticGroup}:${readwrite}` keep only the top scorer
requiresIntake filterHard-excluded when no intake session is linked:15 (header contract)
alwaysIncludePrepended unconditionally, does not count against k:114

applyWorkflowBoost includes intent === 'execute' deliberately: HMS write/intake turns classify as execute, so without it the composite-intake workflow depended solely on the fragile regex fallback (:161-164).

The composite-turn candidate floor

Pure cosine ranking systematically loses specific tools. Three prompt-conditioned floors are composed in workflow-runner.service.ts:281-288 as policyAdditionsbefore retrieval, so they enter as alwaysInclude rather than competing for a top-K slot. All three are regex .test only, never an LLM call.

FloorTriggerWhat it pinsWhy cosine failed
Intake (12 write tools incl. appointment.manage, visit.manage, prescription.manage, communication.send)isMultiEntityCreate(prompt)countMultiEntityMatches ≥ 2 over MULTI_ENTITY_PATTERNSthe clinical mutation familya RAG-crowded write tool (observation.manage/vitals) became un-plannable mid-intake
Research (web-search, fetch-ticker-headlines)isWebResearchTurn(prompt)the only web toolsweb-search ranks ~62/108 on pure cosine even for an explicitly web-shaped query — the clinical corpus dominates the embedding space
Clinical-history (7 read tools + patient.search.smart)isClinicalHistoryTurn(prompt)the clinical read familytopK crowded real read tools below the cut, leaving only a demo tool (agui.showcase) as read-class — the planner could only ship an agent-call-only plan, MR-29 plan-admission rejected it, and the run HARD-FAILED non-retryably (DEF-201)

MULTI_ENTITY_PATTERNS (multi-entity-patterns.ts:23) is a 13-entry regex set tuned so each signal counts once and a single-action turn stays at count=1: appointment-booking, prescribing, visit-start, and note-writing are each ONE signal, so "book an appointment and write a note" reaches 2 and pins the floor while "book an appointment" alone does not. Signals cover en/de/fr, ICD-10 codes, dose units (mg/mcg/IU), and vital-sign units. The comment block is explicit that the matchers are loose on purpose: a false positive only over-boosts workflows for one turn, a false negative silently demotes the composite workflow — the bug being fixed.

The research floor deliberately omits a bare \bsearch\b pattern (research-candidate-set.ts:56): "search" is the most common verb in clinical prompts ("search for the patient") and would fire on nearly every turn.

Patient-resolution floor — structural, not prompt-based

decidePatientResolveFloor (patient-resolve-floor.ts:55) runs after the snapshot resolves (workflow-runner.service.ts:310) and fires on structure, not phrasing. It prepends patient.search.smart when BOTH: some surfaced tool needs a patient id (requiresPatient, or a requiresOneOf naming patientId/patient_id/patient) AND no resolver (PATIENT_RESOLVE_TOOL_IDS) is already surfaced. Without it the planner could not turn a patient NAME into an id, and every patient-scoped step failed missing_required. It floors exactly one tool — the minimum addition — and the runner dedupes against the clinical-history floor which pins the same id.

Floors guarantee visibility, never a call. A floored tool is plannable; the planner still has to choose it.

RAG-outage fallback

The fallback policy lives in the pure, Mastra-free candidate-resolution.ts so it is unit-testable without loading the registry's @mastra/core taproot (the jest-ESM pitfall). decideCandidateResolution (:101) never throws and by construction can never return the full registry — every branch is bounded to budgetMax over ragHits ∪ priorAuditedSet ∪ floorToolIds, and nothing else is in scope.

Retrieval outcomesourceSurfaced setactivationReason
Hits presentragdeduped, capped RAG hitsrag-retrieved, or always-include for floor ids
undefined (embedder/Qdrant threw) or [] (successful-but-empty)priorAuditedSetthe session's last successfully-audited setprior-audited-set
Outage with no prior setfailNarrowthe reviewed floor ids onlyfail-narrow-floor

The distinction between undefined (outage) and [] (empty success) exists only for logging — both take the same bounded fallback path (:568-569, :63-66).

One narrower degradation sits inside search(): a Qdrant 404 on the collection (not yet indexed) degrades to an empty list with a self-heal log line rather than throwing (:706-712); any other Qdrant/embedder error propagates so resolveCandidateSet can record a genuine outage.

Invariant #2 (plans/condor-prompt-slim/_invariants.md): no path may ever yield the full tool registry. A "safety" full-dump on outage defeats the entire feature — it re-introduces the 63.4 KB prompt this subsystem exists to eliminate. The comment on candidate-resolution.ts:12 states this as a hard invariant, and resolveCandidateSet's own doc-comment repeats it.

How it feeds the planner

The resolution becomes an immutable ToolSelectionSnapshot threaded as data into prompt assembly, the plan phase, validation, compilation, observability and persistence — no consumer re-resolves (workflow-runner.service.ts:240-249). Downstream:

  • Prompt blockscopedToolIds become ToolSpec[] carrying a per-tool Zod-derived param synopsis (:330), rendered into AVAILABLE TOOL IDS. The synopsis exists because dudoxx-gemma otherwise guesses param names by ergonomics (cmd vs command) and per-step Zod validation fails mid-run.
  • Token-pressure clamp — measures the COMPLETE rendered per-tool bytes (id + description + params scaffold + newlines), not just param bytes, and digests per-tool. It never drops a surfaced tool (:346-355).
  • Plan validationvalidatePlanToolSurface (candidate-resolution.ts:194, called at workflow-runner.plan.ts:1418) admits or rejects the tools the planner actually named, with broadened-retry-admitted and registry-verified-off-list as additional activation reasons.
  • Broadened retrysearchBroadened (:761) is the retry surface: looser threshold 0.05, K 25, no intent filter (:87-88).
  • Per-subtask refinementperSubtaskMatch (:743) caps at K=6 (PER_SUBTASK_TOP_K) because one subtask needs a tight matched set, not the planner-phase budget.
  • Run report — an optional metricsSink folds topScore + latency + call count into the end-of-run QDRANT section (:126-131).

The builder's monitor surface (builder/condor-tools-monitor.service.ts:17) deliberately calls search()/searchBroadened()never resolveCandidateSet — so an operator probe cannot rewrite a live session's audited-set cache.

Operational Notes

  • rag-outage-fallback.ts does not exist. Only rag-outage-fallback.spec.ts is on disk; the outage policy it tests lives in candidate-resolution.ts. Do not cite the non-existent module.
  • Floors are composed in the runner, not the RAG service. The three prompt-conditioned floors are assembled as policyAdditions at workflow-runner.service.ts:281; resolveCandidateSet only receives them as floorToolIds. Adding a floor means editing the runner.
  • A tool absent from the set can never be picked. When a tool is mis-selected, first check the [dispatch.candidate-set] log line for whether the intended tool was even surfaced — then harden its ragDescription/ragKeywords, or floor it if the phrasing is systematic.
  • Only source === 'rag' writes the cache. A priorAuditedSet or failNarrow turn intentionally does not overwrite the last good set.
  • Domain scoping requires opts.metadata. scopeByDomain: true is a no-op without a metadata lookup (:789-790); a caller passing neither gets plain cosine ranking (back-compat with the pre-T04 search()).
  • Unmapped categories are generic and never penalized. Leaving clinical unmapped once let every clinical tool escape the off-domain penalty entirely (tool-domain-scoping.ts:44-50) — add new categories to CATEGORY_TO_DOMAIN deliberately.
  • Read/display tools must declare a semanticGroup matching their write sibling's or the dedup axis split (read vs write) cannot keep one of each.
  • Dimension guard. If the collection exists at a different vector dim than the embedder emits, the service DROPS and recreates it on init (DEF-132 guard, :393-405). Orphaned points whose toolId left the live registry are pruned (:430).
  • Only plane-filtered business descriptors are indexed (:44-50) — planner-control tools (tool-search, resolve-referents) must not appear as business candidates.
  • Pure modules are pure on purpose. candidate-resolution.ts, multi-entity-patterns.ts, semantic-group-dedup.ts, tool-domain-scoping.ts, patient-resolve-floor.ts and the three floor files import no @mastra/*, so their specs run under the project jest config. Keep new decision logic in this lane rather than inside the @Injectable service.
  • condor-tools-registry — the catalog + descriptor planes this searches over; condor-tool-catalog.service.ts is the application-scoped seam that calls resolveCandidateSet.
  • condor-engine — the runtime that threads the ToolSelectionSnapshot through plan compilation.
  • tucan-rag-tools — the sibling TUCAN subsystem. Same Qdrant + dudoxx-embed substrate, opposite contract: build-time discovery vs per-turn candidate resolution.
  • rag-system-indexation — the user-facing document/knowledge RAG, a different pipeline and different collections.