C3TMC

Agent

How a patient's message becomes a set of verified trials, and the guardrails between the model and the answer.

A turn runs two agents. A small triage agent decides whether the message should be answered at all, then the clinical-trials agent answers it with a set of tools and a set of guards that sit between the model's output and the patient.

The turn, end to end

Colour carries the point.

  • Yellow is a guardrail: the gate before the agent, the deterministic prefetch, the limits on tool use, and the check on what was written.
  • Red is a refusal, where the model's own output is discarded or never asked for.
  • Navy is the ordinary path of the turn, and it is deliberately the smallest part of the picture.
  • Blue is the patient: the message in, and the answer that goes back out.

Built on Pydantic AI

Both agents are built with Pydantic AI, an agent framework from the team behind Pydantic. It is what makes the tools and the answer typed.

Three of its properties decided the choice.

  • A short learning curve. It reads like ordinary Python, so the framework stays out of the way: capable where it needs to be, and flexible enough to shape the turn around the product.
  • Output validators. A validator can reject an answer and hand the model a ModelRetry, which is exactly how the citation and duplicate-call guards are written.
  • Provider-agnostic. The model is chosen by configuration, so swapping providers is an environment change.

Before the agent runs

Input triage

A tool-less classifier agent reads the message and returns a verdict: allow or refuse, plus a category if refused. It sees a short window of recent turns so it can tell a follow-up from a fresh topic.

Refusing does not short-circuit the turn. Instead the main agent runs with every tool hidden and a refusal directive injected into its instructions, so the patient still gets a warm answer. Classifying and answering are kept separate because a model asked to both refuse and stay in character tends to do one of them badly.

Prefetch

If the patient's message contains an NCT number, it is looked up deterministically before the agent runs, and one of two things is attached to the run:

  • Found. The verified record is pinned into the agent's instructions as the only source of truth about that trial, with an instruction to fact-check the patient's description against it and never repeat their version.
  • Not found. The same refusal directive triage uses. It writes the same field, so the tools are hidden for the rest of the turn.

This exists because the failure mode it prevents is specific and bad: a patient describes a trial slightly wrong, and the model politely agrees with them.

Answering

The tools

The set of tools available to the agent.

ToolUse
syntactic_searchHard constraints: cancer type, location, status, phase, plus optional keyword text matching
semantic_searchThe same hard constraints, then ranking by how well a trial matches what the patient described
get_trial_detailsThe full record of a specific trial
define_termPlain-language definition of a medical term

Why the search is split in two

Both searches start from the same hard constraints, which narrow the search space before anything is ranked. What differs is how they read the rest of the message.

syntactic_search matches text patterns, so it is the right tool for a straightforward keyword query: a drug name, a study title, a term the patient quoted. semantic_search matches meaning, so it is the right tool for the details a keyword cannot capture: stage, prior treatment, and the situation the patient described in their own words.

Every call says why

Every tool input carries a reasoning field, which the model fills in before the call is made. It changes nothing about the search, and it is not shown to the patient. It exists so that a trace records not just what the agent did but why it thought it should.

What a tool call leaves behind

Every tool call is recorded on deps, both the raw citations and a compact hit summary. That record is what the citation validator checks against, and what becomes map pins at the end of the turn.

Guardrails

Every guard lives in one file, guards.py, so the whole set can be read in one sitting.

Tool budget

Tools are filtered out of the toolset once the per-run call budget is spent, and whenever the turn was refused by triage. The model does not see a disabled tool and fail to call it; it does not see the tool at all.

Duplicate calls

Each call is canonicalized (trimmed, lowercased, lists sorted, the free-text reasoning field ignored) into a signature. An exact repeat raises a ModelRetry telling the model to change its parameters or answer, which converts a stuck loop into a usable nudge.

Citation enforcement

The output validator does two separate jobs, and the distinction matters:

mentioned = set(_NCT_PATTERN.findall(output.message))
output.used_nct_numbers = [
    nct for nct in output.used_nct_numbers
    if nct in ctx.deps.fetched_trials and nct in mentioned
]

A citation survives only if a tool returned it and it appears in the prose. That keeps the citation list and the answer in agreement, so the map never shows a trial the text does not mention.

unverified = any(
    nct not in ctx.deps.fetched_trials
    for nct in _NCT_PATTERN.findall(output.message)
)
if unverified:
    output.message = _HALLUCINATED_TRIAL_FALLBACK

If the prose names a trial no tool ever returned, the model has probably invented the trial and everything it said about it. Scrubbing the number would leave the invented claims behind, so the whole reply is replaced.

These two checks are not the same check at different strengths. The first keeps the citation list honest; the second treats an invented NCT number as evidence that the reply itself is unsound.

Streaming the answer

The answer is written as it is generated. The reply is streamed. place.

The event flow

Errors are codes, not messages

The route never leaks an exception. Pydantic AI failures are classified into a small stable enum, and the frontend owns the wording.

CodeShown as
usage_limitThis conversation reached its processing limit.
model_unavailableThe assistant is temporarily unavailable.
model_errorI couldn't complete that request. Please try rephrasing.
turnstile_failedWe couldn't verify that you're human.
genericSomething went wrong.

Diagnostic detail stays in the logs and in the Langfuse span.

Session memory

Conversation history lives in Redis, under one key per session with a time to live, and is serialized through Pydantic AI's own message adapter.

The store sits behind a protocol with an in-memory implementation, so tests exercise the same serialization path without Redis.

Last updated on

On this page