Most tool functions I see in agent demos return one of two things: a result, or an exception. Microsoft.Extensions.AI then turns that exception into a polite little string for the model, something like Error: Function failed.

For a read, that's fine. For a write, it's a trap.

Picture a refund tool. The HTTP call to billing goes out, billing commits the refund, and the response gets lost on the way back. Your timeout fires. The tool throws. The model reads "failed", does the helpful thing, and calls the refund tool again. The customer is now 40 EUR richer instead of 20.

The model didn't misbehave. You told it something false. It had no way to know "failed" really meant "I have no idea".

This post is about the small vocabulary I now give every tool in a .NET agent so the model never has to guess. It's one slice of a bigger build (parallel tool calls, per-order lanes, idempotency keys), and I'm deliberately staying on the outcomes here.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, tested project behind this post (a parallel tool-calling support agent in .NET 10 with Clean Architecture and 281 passing tests, plus a step-by-step PDF guide) is inside. Join Tech Skill Builder

Two words aren't enough

Here's the vocabulary, and the one question that decides each word:

Outcome Question it answers What the model should do
ok Did the change happen, and do we have proof? Tell the user it's done
failed Are we sure nothing changed? Explain, maybe try something else
unknown Did the request leave, with no answer back? Say it's being confirmed. Don't repeat it
already_done Did an earlier identical request succeed? Report the earlier result as the answer

Plus one housekeeping state, skipped, for "an identical call is in flight right now, I didn't send this one".

The point isn't the names. The point is that failed becomes a promise: nothing changed. If you can't keep that promise, you're not allowed to say it.

The shape of the result

I return a typed envelope from every tool instead of throwing. The model gets small, boring JSON it can reason about:

public enum ToolOutcome { Ok, Failed, Unknown, AlreadyDone, Skipped } public sealed record ToolResult( ToolOutcome Outcome, string Code, // machine-readable: "timeout_before_send", "still_confirming", ... string Message, // short human sentence for the model JsonElement? Data) // the payload on Ok / AlreadyDone { public static ToolResult Unknown(string message) => new(ToolOutcome.Unknown, "unknown", message, null); // Ok(...), Failed(...), Skipped(...), AsReplay(): full implementation in the complete project } 

And instead of letting FunctionInvokingChatClient call the function directly, I route every call through its FunctionInvoker hook, so nothing ever reaches the model as Error: Function failed.:

var client = new FunctionInvokingChatClient(inner, loggerFactory) { IncludeDetailedErrors = false, FunctionInvoker = async (ctx, ct) => { var result = await executor.ExecuteAsync(ToRequest(ctx), ct); return ToolJson.Envelope(result); // always an envelope, never a raw exception }, }; // ToRequest(...), the executor's cap, lanes and ledger: in the complete project 

Where "unknown" comes from: the dispatch marker

The whole trick is knowing when the request left. So every write handler flips a marker right before it sends anything:

public async Task<ToolResult> RefundAsync(ToolInvocation inv, CancellationToken ct) { // validate arguments, check the order belongs to this customer ... inv.Dispatch.Mark(); // from here on, the change may happen var response = await _billing.RefundAsync(/* ... */, ct); // map the response to Ok / Failed: full implementation in the complete project } 

Then the executor decides what a timeout means based on that marker:

catch (OperationCanceledException) when (timeout.IsCancellationRequested) { result = inv.Dispatch.IsMarked ? ToolResult.Unknown("sent, but no answer in time; it may have happened") : ToolResult.Failed("timeout_before_send", "nothing was sent; nothing changed"); } 

Timeout before the marker: failed is true, and the model can safely try again. Timeout after: the only honest word is unknown.

One thing that bit me while writing this: don't pass the user's cancellation token into a write once it has started. If the customer closes the tab mid-refund, cancelling the HTTP call doesn't cancel the refund. It only guarantees you never learn the result.

Where "already_done" comes from: a key that survives retries

unknown only helps if a repeat can be recognised as a repeat. So each write gets an idempotency key derived from intent, not from the attempt:

public static string Derive(string scope, string toolName, string canonicalArguments) { // SHA-256 over conversation scope + tool name + canonical arguments // full implementation (canonicalisation rules, ignored fields) in the complete project } 

Before sending, the executor claims the key in a store. Three answers come back that map straight onto the vocabulary:

  • already completed → return the stored result as already_done. The model gets the original confirmation, and billing is never called.
  • in flight → skipped, code already_in_progress.
  • unknown (an earlier attempt timed out after sending) → unknown, code still_confirming.

That last one is what kept my "timeout, then the model retries" test at one refund instead of two. The honest answer to "try again" was "no, we're still checking".

A gotcha with derived keys: free-text arguments. The model rewrites "late delivery" as "delivery was late" on the retry, and if reason is part of the key, that's a brand new refund. Mark descriptive fields as ignored for the key.

Don't let the HTTP layer undo all of this

You can get every outcome right in the agent and still refund twice, because of a line you probably already have:

services.AddHttpClient<BillingClient>() .AddStandardResilienceHandler(); // retries transient failures for every method, POST included 

In my test, billing committed the refund and then returned 503. The default handler retried the POST: two refunds, and the agent never saw anything wrong. The fix is to retry only safe methods, or POSTs that carry an Idempotency-Key header so the API can replay the saved answer. The predicate is a handful of lines. I won't paste it here, but the rule is: no key, no retry on writes.

Close the loop: check what the model says

Last piece. The model writes the final reply, and sometimes it says "I've refunded your order" when the ledger says unknown. So after the reply, a small checker looks for success words next to an order whose write wasn't ok or already_done, and if it finds one, it swaps in a summary built from the ledger by code.

It's a tripwire, not language understanding. It can miss creative phrasing. But in my "model claims success" scenario, it caught the false claim before the customer saw it.

The checklist

Before I let an agent call a write tool, I check:

  • [ ] Writes never throw to the model. They return a typed outcome.
  • [ ] failed is only returned when nothing was sent.
  • [ ] Every write handler marks dispatch right before the request.
  • [ ] Every write carries a key derived from conversation + tool + canonical arguments.
  • [ ] A repeated key returns already_done, skipped or still_confirming, never a second send.
  • [ ] Write HTTP clients don't retry unkeyed POSTs.
  • [ ] The final reply is checked against what actually happened.

If any box is empty, the agent can promise things that aren't true, or do things twice.

Get the complete version

This post is the vocabulary and the skeleton. The parts I trimmed are where the real work is: the executor with its concurrency cap and per-order lanes for parallel tool calls, argument canonicalisation, the idempotency store, the pending-action reconciler, the resilience predicate, the reply checker, and the failure tests that prove each scenario sends exactly one refund.

The complete, working, tested project (.NET 10, Clean Architecture, 281 passing tests, runs offline with a scripted model, no API key needed) plus a step-by-step PDF guide is in Tech Skill Builder. It's $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10: https://elitesolutions1.gumroad.com/l/TechSkillBuilder

How do your tools report a timeout today: "failed", or something more honest? I'd like to hear how others handle it in the comments.