Which Agentic Chatbot?
I have been working on a Python based AI test framework for a chatbot interface for my company's product, Postgres AI Hybrid Manager. The manager allows the setup of Postgres clusters across cloud or on-prem and attaching various AI tools such as Langflow. So a combination of more traditional Postgres backup, migration, telemetry and analytics features along with LLM workflows leveraging the data it holds.
The product already has a control plane UI for managing Postgres estates. It also has full help for the product, all Postgres versions, analytics, AI and add ons. The chatbot brings all these things together: ask a question, get the relevant help, or ask it to do something such as migrate a cluster, or evaluate telemetry that would otherwise require clicking through the UI.
That makes it a pretty handy interface, especially for the less technical. However it is not a simple to test and ensure good quality responses.
A normal deterministic API test is simple. Send a request, check the status code, check the JSON body, perhaps check the database state. An LLM-backed agent does not pass or fail so clearly. It can route to the wrong capability and still return fluent text. It can pick a plausible but wrong tool. It can miss half the task and still sound confident. It can complete the first turn of a conversation and lose the plot on the second. It could get malformed or missing data from tooling that leads it to deliver a misleading conclusion. It might only provide help to something that should be from tool data or was a request for an action such as create a cluster.
So the testing problem was not “does the chatbot return a reasonable response?” It was “how do we test the whole chat path is doing the right thing?”
This is the story of how our agent-eval test framework evolved as we worked to see that our chatbot was not only getting the right answer, 42 , but whether it was asking all the right questions of the right tools to get that answer. Known as trajectory testing ...
You're Golden
Before we can tell our story we need to define some terms.
A Golden is an example of a perfect desired output from a test input. They often refer to more complex outputs that may need saving as separate files, but a simple assertable output such as 42, that is a golden too!
Whilst complex goldens may be used and marked for semantic simularity against the test output. It is more common for complex outputs to be described by a rubric. A rubric is a checklist of qualitative properties a good answer must exhibit, written in plain English as opposed to a golden example of an answer.
For AI testing the tests are termed evals, ie they evaluate the tool, but not by strict assertions, because one thing you can be sure of with an LLM is that given the same input, you usually get subtly different output, ie they are non-deterministic. Which means for LLM outputs the only way to test them is to use an LLM-as-judge, ie give that LLM the test output and a rubric or golden and let it mark it against that. Then you set a pass threshold for that mark, to translate your complex output into a pass or fail.
You can also total up all the passes to give you a Task Completion Rate, TCR. So with complex AI agentic LLM interactions a 100% pass of all evals is often not realistic. Hence you set a TCR below 100% for the whole test suite of evals to pass. Start with the smallest useful test. The core principle of evals is not complicated, you want the input to give you the expected output.
But for an Agentic application this may require a sequence of LLM calls and tools: Making the final output dependent on the route that should be chosen, the tool(s) that should be called, the actions to be taken, further LLM calls that may be necessary and finally the core data that the response to the user should contain.
Our first version did not try to solve every part of that. It started with routing, simple and deterministic.
Routing is the starting point
The chatbot originally had an agent per tool. The tool being the code and API calls that performed actions or returned data or help.
Different specialist agents owned different parts of the product surface: Control-plane actions, Postgres database operations, schema design, roles and permissions, cluster reporting, migration, and so on.
Before any specialist can help, something has to choose the right specialist.
So the first eval suite asked a narrow question:
Given this user prompt, did the chatbot route to the expected tool?
That gave us a fast health check. We could keep a corpus of prompts, map each one to an expected destination, run them through either a direct model path or the real proxy, and score whether the selected destination matched the golden.
A golden here is just the name of the tool:
- id: "core-iam-001"
prompt: "List all my projects"
expected_tool: "control-plane"
tags: ["core", "control-plane", "project"]
And the check on the other end is deliberately dumb — an equality test, not a semantic one:
self.success = tool_match(predicted_tool, expected_tool)
Agents became skills, but routing remained
The design moved away from “one agent per tool family” toward a more consolidated orchestrating agent with skills.
That is a better fit for how modern agent systems are evolving. A skill = instructions, constraints, and a subset of tools that are relevant for a task. It is a form of progressive disclosure. Give the model the minium it needs at each step to save tokens.
But this did not make routing irrelevant. It changed what routing meant.
Instead of asking “did we transfer to the right sub-agent?”, the eval asks “was the right skill made visible and selected for this task?” The labels changed but a skill could still use the wrong tool.
Routing evals stayed valuable because they were fast, explainable, and easy to run in CI. But they are limited, routing should always be correct but it doesn't mean that the final agent response is too.
TCR jumps to the endpoint, the response
Task Completion Rate, or TCR, was the next step.
The user asked for a cluster comparison, or a schema recommendation, or help diagnosing a database issue. We need to know whether the full response actually completed these tasks.
Responses are complex goldens so they need the LLM-as-a-judge pattern: run the chatbot, take the actual response, and ask a judge model to score it against expected sections.
The eval has a rubric here for judging the output:
- id: "tcr-core-014"
prompt: "Compare CPU usage between these two clusters"
expected_sections:
- "identifies which cluster has higher CPU usage"
- "cites at least one supporting metric"
- "suggests a plausible next step"
The judge gets one simple instruction: score each expected_sections between 0.0–1.0 A metric class then just thresholds it for pass / fail:
self.success = score >= 0.7
The judge must be calibrated and a consistent model used for comparing runs over time. Enabling skill an prompt tuning from metric trends. The rubric must be specific enough to avoid marking waffle as success. But it turns a non-deterministic complex output into a simple pass and fail. It also separated two different levels of QA:
- Can the underlying model answer the task if given the right context?
- Does the deployed chatbot complete the task through the real product path?
That led to two execution modes.
Direct mode calls the model with simulated context. It is faster and useful for prompt and rubric development.
Proxy mode calls the real chatbot through upm-agent-proxy. It is slower, but it exercises the production path: routing, skill selection, tool calls, guardrails, streaming responses, conversation state, and the actual service wiring.
Both matter. Direct mode tells you whether the model is capable of the answer. Proxy mode tells you whether your product is capable of delivering it.
Multi-step conversations changed the unit of testing
Single-turn TCR is still too small for many real chatbot tasks.
Users do not always provide all required information in one message. They ask to create a cluster, then pick a project, then choose a size, then confirm. They ask for a schema review, then refine the problem, then ask for a migration path. They troubleshoot by adding information over time.
So the framework has to exercise test cases that are conversations, not just single prompts.
That sounds like a minor data-model change. It was not. Once a test has steps, the eval runner has to preserve conversation state. In proxy mode, that means carrying the real conversation_id returned by the chatbot and sending each follow-up as part of the same server-side conversation. In direct mode, it means building a synthetic conversation history so the model sees the prior turns.
In code that split is about as literal as it sounds. Proxy mode threads a real id through each call:
response = client.send_message(prompt=msg, conversation_id=conversation_id)
conversation_id = response.conversation_id # captured on turn 1, reused after
Direct mode has no server-side conversation to lean on, so it fakes one by re-rendering the transcript into the prompt itself, every turn:
full_prompt = f"## Conversation History\n{render(history)}\n\n{next_prompt}"
Same test case, same expected outcome, but a different code path depending on which half of the system is actually holding the conversation state. That's impacts multi-turn evals because conversation memory is part of the harness code for the actual deployment not just a model issue.
The scoring also becomes more interesting. You want per-step checks, because the assistant should ask the right clarifying question at the right time. You also want an overall score, because a conversation can have reasonable individual turns and still fail to complete the user's goal.
Coding it yourself: deepeval underneath
Everything above sits on top of deepeval, the open-source LLM eval library. We add a Synthesize → Execute → Evaluate pipeline, a plugin system, YAML goldens, CI wiring, and Langfuse push on top of it But the core library underneath is plain deepeval, and you do not need any of the surrounding machinery we used. Here are routing, TCR and multi-step just built directly on deepeval (simplified deepeval 3.6.9)
A test case is just an input/output pair. LLMTestCase is the base unit everything else scores:
from deepeval.test_case import LLMTestCase
test_case = LLMTestCase(
input="List all my projects",
actual_output=chatbot_response_text, # what the system under test said
expected_output="control-plane", # the golden - a skill label here, not prose
additional_metadata={"predicted_skill": predicted_skill},
)
Routing is a custom metric, not a built-in one. deepeval ships plenty of semantic metrics, but “did it route to the right skill” is an exact-match business rule, so you write your own BaseMetric. This is a simplified version of the same shape our real AgentMatch metric takes:
from deepeval.metrics import BaseMetric
from deepeval.test_case import LLMTestCase
class AgentMatch(BaseMetric):
def __init__(self, threshold: float = 1.0):
self.threshold = threshold
self.async_mode = False # routing checks are cheap; no need for async here
def measure(self, test_case: LLMTestCase) -> float:
predicted = test_case.additional_metadata["predicted_skill"]
expected = test_case.expected_output
self.score = 1.0 if tool_match(predicted, expected) else 0.0
self.success = self.score >= self.threshold
return self.score
async def a_measure(self, test_case: LLMTestCase) -> float:
return self.measure(test_case)
def is_successful(self) -> bool:
return bool(self.success)
@property
def __name__(self):
return "Agent Match"
tool_match is the check from earlier. Run it with deepeval's own runner rather than hand-rolled assertions, and you get retries, pretty output, and a result object for free:
from deepeval import evaluate
evaluate(test_cases=[test_case], metrics=[AgentMatch()])
TCR is where deepeval's built-in GEval earns its keep. GEval is deepeval's off-the-shelf LLM-as-judge metric, you give it criteria (or explicit evaluation steps) and it handles the judge prompt, the JSON parsing, and the scoring for you. Our rubric-per-line expected_sections maps onto evaluation_steps almost directly:
from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCase, LLMTestCaseParams
task_completion = GEval(
name="TaskCompletion",
evaluation_steps=[
"Check whether the response identifies which cluster has higher CPU usage",
"Check whether the response cites at least one supporting metric",
"Check whether the response suggests a plausible next step",
],
evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],
threshold=0.7,
)
test_case = LLMTestCase(
input="Compare CPU usage between these two clusters",
actual_output=chatbot_response_text,
)
evaluate(test_cases=[test_case], metrics=[task_completion])
Multi-step conversations get their own test case type. ConversationalTestCase takes a list of Turns instead of a single input/output pair, and pairs with a BaseConversationalMetric instead of BaseMetric:
from deepeval.test_case import ConversationalTestCase, Turn
convo = ConversationalTestCase(
turns=[
Turn(role="user", content="Create a new cluster"),
Turn(role="assistant", content="Sure - which project should it go in?"),
Turn(role="user", content="acme-prod"),
Turn(role="assistant", content=final_response_text),
],
expected_outcome="A cluster is created in acme-prod after resolving the missing project name",
)
deepeval has a conversational counterpart to GEval too (ConversationalGEval), scored against the whole turn sequence rather than a single response which is the natural fit for “did the assistant ask the right clarifying question at the right time”, the per-step-plus-overall shape TCR needed once prompts became conversations.
Put together, that is the whole starting kit: LLMTestCase plus a hand-written BaseMetric for hard business rules like routing, GEval for rubric-style task completion, ConversationalTestCase plus ConversationalGEval once a prompt becomes a conversation, and evaluate() to run the lot and get a result object back.
Everything else we built, the YAML goldens, the plugin architecture, the CI wiring, the Langfuse push exists to run more of these at scale and make the failures easy to find. But none of it is required to get started. If you are testing your own agentic chatbot, this is how to begin.
This is where instrumentation started to matter much more.
For a single-turn answer, a markdown report with pass/fail rows is often enough to start debugging. For multi-step conversations, that is thin. You need to know which turn failed, whether the route changed, whether the wrong tool was called, whether the tool call used correct arguments, whether the model forgot earlier context, or whether the final answer simply missed a required section.
That is why we added span-level telemetry and pushed eval traces into Langfuse.
Langfuse made the failures inspectable
The useful thing about Langfuse is not just having another pretty dashboard.
The useful thing was being able to treat an eval run as a set of traces. A run becomes a session. Each test case becomes a trace. The trace carries the prompt, response, scores, tags, model, mode, scenario, and the spans emitted by the proxy.
For a chatbot path, those spans are where the debugging starts. You can see routing, tool execution, LLM calls, latency, and token usage where it is available. You can filter by scenario and model. You can compare runs. You can look at a failing conversation and see whether the problem began at route selection, tool selection, tool arguments, or final synthesis.
That changes the tuning loop.
Without traces, an eval failure says “this case failed”. With traces, it can say “this case failed because the right skill was selected, but the wrong tool arguments were passed on step three”, or “the tool path was fine, but the final answer missed two required sections”.
That distinction matters because the fix lands in different places...
Is it a routing rule?
Is it a skill description?
Is it a tool schema?
Is it the judge rubric?
Is it that the eval has has an expectation that the product has never actually promised?
Trajectory testing -> knitted the pieces together
Routing and TCR started as separate signals.
Routing asked whether the right capability was selected. TCR asked whether the final task was completed. Multi-step testing asked whether that held across a conversation. Instrumentation showed what happened between those points.
Trajectory testing is the next natural step: score the path itself.
For an agentic product, the fully correct path is essential to response quality.
So trajectory tests add expectations about intermediate actions:
- which tool or flow should be used
- whether the arguments are valid
- whether the conversation reached the right state
- whether the final answer completed the task
The label-based routing tests are still useful as fast canaries. They tell us whether the classifier shape has drifted and distinguish tiers - see the next section.
But full trajectory tests judge the route by consequence: did the system actually follow the tool path that would satisfy the user?
So retain the fast determisitc routing tests, but move more user-visible behavioural coverage into trajectory and TCR.
Sovereign AI makes the eval problem tiered S/M/L/XL
There is one more constraint that makes this more than a generic chatbot-testing story.
Our chatbot has to work for sovereign and air-gapped deployments. In those environments, prompts, tool results, schema details, and operational data cannot be sent to a hosted frontier model outside the customer's trust boundary. The inference model may run inside the customer's environment.
That usually means a smaller model.
Smaller models are not just cheaper versions of larger ones. They have different context limits, weaker tool-selection behaviour, and less tolerance for an over-wide capability surface. If you show a smaller model every possible tool and skill, you have increased the chance that it chooses a bad one.
So the architecture becomes tiered. Models are effectively T-shirt sized. A small self-hosted model sees a curated subset of reliable skills. A larger model can be allowed to see more. Some experimental or complex skills only make sense for the highest tiers.
That changes the meaning of a routing eval again.
The correct visible skill set is no longer universal. It depends on the model tier. A prompt that should route to an advanced skill for an XL model may need to be dropped, refused, or handled differently for a smaller model that should not see that skill at all.
This is why trajectory testing and routing need to be tier-aware. We are not only asking whether the chatbot can complete a task. We are asking whether it can complete the task through the capability surface that a deployment's LLM size allows.
What I would keep from the journey
The final shape was not obvious at the start.
We began with routing because it was the first integration failure point and the cheapest one to isolate. We added TCR because correct routing did not prove task completion. We added multi-step cases because real users have conversations, not isolated prompts. We added telemetry because multi-step failures are otherwise too hard to debug. We moved toward trajectory testing because the route, tools, arguments, and answer need to be judged as one path.
If I were starting another agentic product eval framework, I would keep that order.
Do not start by trying to build a grand universal benchmark. Start with the smallest failure point that would embarrass the product if it regressed. Then move the signal closer to the user's actual goal.
For a chatbot wired into a real control plane, that means testing more than the output text. It means testing the route, the skill, the tool call, the arguments, the conversation state, the final answer, and the model tier that made those options visible in the first place.
That is the difference between checking that an AI system said something vaguely relevant and checking that it actually did all the things the user asked of it.