AI Agent Tools — Giving Agents Access to Systems
Build AI agent tools in .NET: defining tool functions, tool schemas, tool execution, error handling from tools, and designing safe tool sets for clinical AI agents.
What Makes a Good Agent Tool
An agent tool is a function the AI can call to interact with the world.
It is the agent's hands — without tools, the agent can only reason, not act.
Good tool design principles:
→ Single responsibility: each tool does one thing well
→ Clear description: the AI decides WHEN to call a tool based on its description
→ Typed parameters: well-named, clearly described parameters
→ Error returns: return error messages (not exceptions) the AI can reason about
→ Idempotent reads: reading data should never change state
→ Guarded writes: state-changing tools should validate before acting
Bad tool designs that cause agent failures:
→ Vague descriptions: "does stuff with prescriptions" — AI can't decide when to call it
→ Too many parameters: AI gets confused, passes wrong values
→ Exceptions on errors: agent crashes instead of recovering
→ God tools: one tool that does everything — AI mis-uses it constantlyDefining Clinical Tools
// Plugin: a group of related tools for a clinical agent
public sealed class PrescriptionTools
{
private readonly IPrescriptionRepository _prescriptions;
private readonly IPatientRepository _patients;
public PrescriptionTools(
IPrescriptionRepository prescriptions,
IPatientRepository patients)
{
_prescriptions = prescriptions;
_patients = patients;
}
[KernelFunction("find_patient")]
[Description(
"Looks up a patient by their Medical Record Number (MRN). " +
"Returns patient name, ward, and date of birth. " +
"Use this first before looking up prescriptions. " +
"Returns null if no patient exists with that MRN.")]
public async Task<PatientInfoDto?> FindPatientAsync(
[Description("The patient's MRN, e.g. MRN-001 or MRN001")]
string mrn,
CancellationToken ct = default)
{
// Normalise MRN format
var normalisedMrn = mrn.Replace("-", string.Empty).ToUpperInvariant();
var patient = await _patients.GetByMrnAsync(PatientMrn.Of(normalisedMrn), ct);
if (patient is null) return null;
return new PatientInfoDto(
PatientId: patient.Id.Value,
FullName: patient.FullName.ToString(),
Ward: patient.Ward?.Name ?? "Unassigned",
DateOfBirth: patient.DateOfBirth.ToString("dd MMM yyyy"));
}
[KernelFunction("list_active_prescriptions")]
[Description(
"Lists all currently active (approved) prescriptions for a patient. " +
"Requires the patient's MRN. " +
"Returns medication name, dose, frequency, and prescriber. " +
"Returns an empty list if the patient has no active prescriptions.")]
public async Task<IReadOnlyList<PrescriptionSummaryDto>> ListActivePrescriptionsAsync(
[Description("Patient MRN")] string mrn,
CancellationToken ct = default)
{
var prescriptions = await _prescriptions.GetActiveByMrnAsync(PatientMrn.Of(mrn), ct);
return prescriptions.Select(p => new PrescriptionSummaryDto(
p.MedicationName.Value,
$"{p.DoseAmount} {p.DoseUnit}",
p.Frequency,
p.PrescriberName)).ToList();
}
[KernelFunction("get_prescription_detail")]
[Description(
"Gets detailed information about a specific prescription including INR history. " +
"Use when you need more detail than list_active_prescriptions provides. " +
"Requires the prescription ID (not the MRN).")]
public async Task<PrescriptionDetailDto?> GetPrescriptionDetailAsync(
[Description("Prescription ID (GUID)")]
Guid prescriptionId,
CancellationToken ct = default)
{
var prescription = await _prescriptions.GetByIdAsync(
PrescriptionId.Of(prescriptionId), ct);
if (prescription is null) return null;
return new PrescriptionDetailDto(
prescription.Id.Value,
prescription.MedicationName.Value,
prescription.Status.ToString(),
prescription.InrValue,
prescription.InrCheckedAt);
}
}Error Handling in Tools
// Tools should return error information the agent can reason about
// NOT throw exceptions (agent crashes) or return null for all errors (agent confuses null)
[KernelFunction("check_drug_interaction")]
[Description(
"Checks for potential drug interactions between two medications. " +
"Returns 'No known interaction' if safe, or a description of the interaction if found. " +
"Returns an error message if the medication name is not recognised.")]
public async Task<string> CheckDrugInteractionAsync(
[Description("First medication name")] string medication1,
[Description("Second medication name")] string medication2,
CancellationToken ct = default)
{
var med1 = await _medicationLookup.FindAsync(medication1, ct);
if (med1 is null)
return $"Error: Medication '{medication1}' not found in the formulary. Check the spelling.";
var med2 = await _medicationLookup.FindAsync(medication2, ct);
if (med2 is null)
return $"Error: Medication '{medication2}' not found in the formulary. Check the spelling.";
var interaction = await _interactionDatabase.CheckAsync(med1.Id, med2.Id, ct);
return interaction?.Description ?? "No known interaction between these medications.";
}
// The agent reads "Error: Medication 'Warfrin' not found" and can respond:
// "I couldn't find 'Warfrin' in the formulary — did you mean 'Warfarin'?"
// Rather than crashing or silently failing.Tool Safety: Read-Only vs Write Tools
// Clearly separate read and write tools
// Register them in separate plugins with explicit naming
// ReadPlugin: safe to auto-invoke
kernel.Plugins.AddFromObject(new PrescriptionReadTools(prescriptions), "PrescriptionRead");
// WritePlugin: requires additional confirmation logic
kernel.Plugins.AddFromObject(new PrescriptionWriteTools(prescriptions), "PrescriptionWrite");
// Write tool design — always validate and describe the action:
public sealed class PrescriptionWriteTools
{
[KernelFunction("suspend_prescription")]
[Description(
"Suspends an active prescription. " +
"IMPORTANT: Only call this after the user has explicitly confirmed the suspension. " +
"Returns 'success' if suspended, or an error message if the suspension cannot proceed.")]
public async Task<string> SuspendPrescriptionAsync(
[Description("Prescription ID to suspend")] Guid prescriptionId,
[Description("Reason for suspension (required for MHRA audit)")] string reason,
[Description("ID of the clinician authorising the suspension")] Guid authorisedBy,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(reason))
return "Error: Suspension reason is required for clinical audit purposes.";
var prescription = await _prescriptions.GetByIdAsync(
PrescriptionId.Of(prescriptionId), ct);
if (prescription is null)
return $"Error: Prescription {prescriptionId} not found.";
var result = prescription.Suspend(reason, authorisedBy, DateTime.UtcNow);
if (result.IsFailure)
return $"Error: {result.Error.Message}";
await _prescriptions.SaveAsync(prescription, ct);
return $"Prescription {prescriptionId} suspended successfully. Reason: {reason}";
}
}Tool Discovery and Documentation
When registering many tools, the AI needs good descriptions to pick the right one.
Testing tool descriptions:
1. Write the tool description
2. Ask a human: "Given this description, when would you call this function?"
3. If they can't answer clearly, rewrite the description
Description quality checklist:
✓ States what data the tool returns
✓ States what inputs are required
✓ States when to use it vs. related tools ("use this BEFORE calling X")
✓ States what happens when data is not found
✓ States any side effects for write tools
✗ Uses jargon the LLM won't understand
✗ Is vague about return format
✗ Omits the distinction between similar toolsProduction issue I've seen: An agent had two similar tools:
get_prescription(by ID) andfind_prescription(by patient MRN). The descriptions were nearly identical: "Gets prescription information." The agent would callget_prescriptionwith a patient MRN (wrong tool for MRN) or callfind_prescriptionwith a prescription ID. The tools acceptedstringparameters, so no type error occurred — but the queries returned no data, and the agent concluded "no prescription found" when a prescription existed. Rewriting descriptions to say explicitly "Use find_prescription when you have a patient MRN. Use get_prescription when you have a prescription ID (GUID)." eliminated all tool selection errors.
Key Takeaway
Good agent tools have single responsibilities, clear descriptions that explain when to call them and what they return, typed parameters with explicit descriptions, and return error strings (not exceptions) that the agent can reason about. Separate read and write tools — auto-invoke reads, require explicit confirmation for writes. Test tool descriptions with humans before testing with the agent. The agent's intelligence is bounded by the quality of your tool descriptions — vague descriptions produce wrong tool selections.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.