Condor RAG Tool Search — Per-Turn Candidate-Set Resolution
Audiences: developer, internal
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:
- 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.
- Selection quality.
dudoxx-gemmapicks worse from a longer list. A domain-scoped, deduped, relevance-ranked set of ~12 improves plan validity more than any prompt-wording change. - 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
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).
| Step | Behavior | Site |
|---|---|---|
| 1. Prompt hash | sha256(trim(prompt)), first 16 hex chars | :561 |
| 2. Same-turn cache | Session entry with a matching promptHash returns verbatim — one embed call feeds both prompt assembly and the "Recommended tools" block | :562 |
| 3. Budgeted search | k = CANDIDATE_BUDGET_BY_PHASE.planner.max (12), scopeByDomain: true, alwaysInclude: floorToolIds | :566, :572 |
| 4. Failure capture | Any throw → hits = undefined, WARN log, never rethrown | :581 |
| 5. Pure decision | decideCandidateResolution({ragHits, priorAuditedSet, floorToolIds, budgetMax}) | :589 |
| 6. Cache write | Only when source === 'rag' — a degraded turn must not overwrite the last good set. Bounded FIFO at 500 sessions | :596 |
| 7. Audit log | source, 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.
Scoring pipeline inside search
search() (:629) embeds via dudoxx-embed and queries the
QDRANT_COLLECTIONS.condorTools collection with cosine distance, then
postProcess (:773) applies, in order:
| Stage | Rule | Constant / site |
|---|---|---|
| Short-prompt fast path | < 3 chars → [] | MIN_PROMPT_LENGTH, :638 |
| Over-query | Metadata-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 penalty | Off-domain candidates lose 0.18 — soft demotion, never a hard exclude; generic is always exempt | tool-domain-scoping.ts:181, :191 |
| Priority tiebreak | Equal scores → higher priority wins; workflows get a floor of 75, others default 50 | :85-86, :170 |
| Semantic-group dedup | Keep all workflows + ungrouped; per `${semanticGroup}:${read | write}` keep only the top scorer |
requiresIntake filter | Hard-excluded when no intake session is linked | :15 (header contract) |
alwaysInclude | Prepended 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 policyAdditions — before retrieval, so they enter as alwaysInclude
rather than competing for a top-K slot. All three are regex .test only, never
an LLM call.
| Floor | Trigger | What it pins | Why cosine failed |
|---|---|---|---|
Intake (12 write tools incl. appointment.manage, visit.manage, prescription.manage, communication.send) | isMultiEntityCreate(prompt) — countMultiEntityMatches ≥ 2 over MULTI_ENTITY_PATTERNS | the clinical mutation family | a RAG-crowded write tool (observation.manage/vitals) became un-plannable mid-intake |
Research (web-search, fetch-ticker-headlines) | isWebResearchTurn(prompt) | the only web tools | web-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 family | topK 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 outcome | source | Surfaced set | activationReason |
|---|---|---|---|
| Hits present | rag | deduped, capped RAG hits | rag-retrieved, or always-include for floor ids |
undefined (embedder/Qdrant threw) or [] (successful-but-empty) | priorAuditedSet | the session's last successfully-audited set | prior-audited-set |
| Outage with no prior set | failNarrow | the reviewed floor ids only | fail-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 oncandidate-resolution.ts:12states this as a hard invariant, andresolveCandidateSet'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 block —
scopedToolIdsbecomeToolSpec[]carrying a per-tool Zod-derived param synopsis (:330), rendered intoAVAILABLE TOOL IDS. The synopsis exists becausedudoxx-gemmaotherwise guesses param names by ergonomics (cmdvscommand) 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 validation —
validatePlanToolSurface(candidate-resolution.ts:194, called atworkflow-runner.plan.ts:1418) admits or rejects the tools the planner actually named, withbroadened-retry-admittedandregistry-verified-off-listas additional activation reasons. - Broadened retry —
searchBroadened(:761) is the retry surface: looser threshold0.05, K25, no intent filter (:87-88). - Per-subtask refinement —
perSubtaskMatch(:743) caps atK=6(PER_SUBTASK_TOP_K) because one subtask needs a tight matched set, not the planner-phase budget. - Run report — an optional
metricsSinkfoldstopScore+ 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.tsdoes not exist. Onlyrag-outage-fallback.spec.tsis on disk; the outage policy it tests lives incandidate-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
policyAdditionsatworkflow-runner.service.ts:281;resolveCandidateSetonly receives them asfloorToolIds. 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 itsragDescription/ragKeywords, or floor it if the phrasing is systematic. - Only
source === 'rag'writes the cache. ApriorAuditedSetorfailNarrowturn intentionally does not overwrite the last good set. - Domain scoping requires
opts.metadata.scopeByDomain: trueis a no-op without a metadata lookup (:789-790); a caller passing neither gets plain cosine ranking (back-compat with the pre-T04search()). - Unmapped categories are
genericand never penalized. Leavingclinicalunmapped once let every clinical tool escape the off-domain penalty entirely (tool-domain-scoping.ts:44-50) — add new categories toCATEGORY_TO_DOMAINdeliberately. - Read/display tools must declare a
semanticGroupmatching their write sibling's or the dedup axis split (readvswrite) 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 whosetoolIdleft 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.tsand 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@Injectableservice.
Related Topics
- condor-tools-registry — the catalog + descriptor planes this searches over;
condor-tool-catalog.service.tsis the application-scoped seam that callsresolveCandidateSet. - condor-engine — the runtime that threads the
ToolSelectionSnapshotthrough plan compilation. - tucan-rag-tools — the sibling TUCAN subsystem. Same Qdrant +
dudoxx-embedsubstrate, 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.