The refund tool fired twice
An agent orchestrating customer support proposes a refund through a payment API tool. The HTTP call times out after thirty seconds. The runtime retries because the planner marks the step as incomplete. Finance receives two credits for one complaint. Logs show two tool invocations with different correlation IDs but the same customer intent. There was no human approval gate, no idempotency key bound to the approved action, and no policy class that treats money movement as irreversible. The demo worked on happy paths; production needed human-in-the-loop routing for side effects.
Problem in agent routing terms
Tool-using agents in .NET services follow a loop: model proposes tool calls, runtime executes, results return to context. Side effects split into three classes: read-only queries, reversible writes, and irreversible or high-impact actions (payments, account closure, bulk email, privilege elevation). Treating all tools as equal latency steps is how duplicate refunds and accidental data exports happen.
sequenceDiagram
participant User
participant Agent as Agent runtime
participant Policy as Policy router
participant Queue as Approval queue
participant Human as Reviewer
participant Tool as Payment API
User->>Agent: Resolve billing dispute
Agent->>Policy: Propose refund £120
Policy->>Policy: Class HIGH → require HITL
Policy->>Queue: Enqueue pending action
Queue->>Human: Notify reviewer
Human->>Queue: Approve + sign intent token
Queue->>Agent: Release execution token
Agent->>Tool: refund(idempotencyKey=intent)
Tool-->>Agent: 200 OK
Human-in-the-loop (HITL) is not “ask a manager sometimes.” It is a routing decision with measurable latency budgets, audit records, and execution tokens that bind a single approved intent to a single tool invocation.
Why generic agent tutorials fail
Microsoft’s AI agent design patterns describe planners, tool registries, and feedback loops. Many samples register tools with descriptions only and execute on model output without a policy layer. Models hallucinate parameters; retries duplicate side effects; concurrent sessions race on the same account.
The NIST AI Risk Management Framework emphasises governance, mapping, and measurement for systems with automated decisions. Regulated SaaS needs traceable approval records, not chat transcripts alone.
Tool side-effect routing table
| Side-effect class | Examples | HITL required | Max auto latency | Audit fields |
|---|---|---|---|---|
| READ | Get balance, search tickets | No | Sync | tool, args hash, actor |
| REVERSIBLE | Update draft note, tag case | Optional policy | 30 s | before/after snapshot |
| HIGH | Refund, transfer, delete PII | Yes, 100% | Human SLA | approver, intent token, idempotency key |
| BULK | Email segment >500 users | Yes | Human SLA | recipient count, template version |
Architecture rule: No irreversible side effect executes without a human or policy approval token cryptographically bound to the intent payload and consumed exactly once at the tool adapter.
Decision event schema and .NET gate
Pending actions serialize to a decision record before queueing:
{
"decisionId": "dec_01HY…",
"sideEffectClass": "HIGH",
"toolName": "payments.refund",
"arguments": { "orderId": "ord_882", "amount": 12000, "currency": "GBP" },
"argumentsHash": "sha256:9f3…",
"requestedBy": "agent:support-v3",
"status": "pending_approval"
}
Runtime gate in the tool dispatcher:
public async Task<ToolResult> DispatchAsync(ToolCall call, CancellationToken ct)
{
var cls = _registry.GetSideEffectClass(call.Name);
if (cls < SideEffectClass.High)
return await _executor.RunAsync(call, ct);
var pending = await _ledger.EnqueueAsync(call, ct);
var approval = await _approvals.WaitAsync(pending.DecisionId, ct);
if (!approval.IntentToken.Verify(call, pending.ArgumentsHash))
throw new InvalidOperationException("Approval token mismatch");
return await _executor.RunAsync(call with {
IdempotencyKey = approval.IntentToken.IdempotencyKey
}, ct);
}
The intent token verifies argument hash match so an approver cannot be tricked into signing a different amount than the agent requested. Idempotency keys flow to payment adapters that support HTTP Idempotency-Key headers or provider-native deduplication.
Implementable checklist
- Classify every registered tool with READ, REVERSIBLE, HIGH, or BULK; block unclassified tools in production builds.
- Persist pending decisions to an append-only ledger table; expose reviewer UI or Slack interactive approval with timeout.
- Reject tool execution when approval latency exceeds policy; surface to human queue rather than auto-approving.
- Integration test: agent proposes HIGH call, execution blocked until approval row exists, second retry without new approval does not double-charge.
- Metric: p95 approval latency, duplicate-tool-call rate, percentage of HIGH actions approved vs rejected.
- Incident class A on duplicate financial side effect; freeze agent auto-execution until root cause in retry or token logic is patched.
Retry, timeout, and duplicate invocation policy
Tool adapters must distinguish idempotent reads from side-effecting writes at the HTTP client layer. Timeouts on payment APIs should not trigger blind retries in the agent loop. Configure per-class retry: READ tools may retry three times with exponential backoff; HIGH tools retry zero times until the ledger shows whether the first call committed. Many providers return 202 with a processing ID; persist that ID in the decision record before asking a human to approve a second attempt.
Concurrent sessions on the same customer account need optimistic locking or account-level mutex keys in Redis, separate from HITL. Two agents must not enqueue conflicting HIGH actions while one approval is pending. The approval UI should show in-flight decisions for the same aggregate root and block double submission.
Model upgrades change tool-call frequency. After swapping planner models, compare HIGH enqueue rate per thousand sessions for two weeks. Spikes often mean the new model interprets policy boundaries differently, not that humans got slower.
Audit export and replay
Decision records export to immutable storage nightly: decision ID, arguments hash, approver identity, intent token ID, tool result correlation ID, and model version. Replay in a sandbox replays the approved intent against stub tools to verify token verification logic after refactors. Regulators ask for “who authorised the refund,” not for chat transcripts alone.
Separate telemetry from the ledger. Application Insights spans around tool calls help latency analysis; they are not a system of record. The ledger table (or event store) owns retention policy, legal hold flags, and redaction rules for GDPR erasure requests that must not delete financial audit rows.
Prompt instructions are not policy
Teams sometimes add “always ask before refunding” to the system prompt. Models ignore it under pressure, across languages, or after context truncation. Policy belongs in code paths the model cannot bypass: registry classification, dispatcher gates, and database constraints on financial tables. Prompts explain tone; they do not enforce side-effect boundaries.
Shadow mode helps rollouts: log HIGH proposals without executing, compare model proposals to human decisions for two weeks, then enable execution with HITL. Measure disagreement rate; high disagreement means classification or tool descriptions need refinement before you remove humans from the loop for REVERSIBLE classes.
Ops gates and ownership
Product defines which tools are HIGH. Engineering owns token verification and idempotency propagation. Compliance owns retention on decision records (typically aligned with financial record retention). p95 approval latency under five minutes is acceptable for support refunds; bulk comms may allow longer windows with scheduled send slots.
Agents that call real APIs are production services, not chat widgets. HITL gates belong in the dispatcher, not in prompt instructions asking the model to “be careful.” Teams shipping agentic features on .NET need the same rigour as payment microservices: policy routing, ledger, and human signatures where impact exceeds reversibility.
For decision ledgers, approval workflows, and agent tool hardening on ASP.NET Core, see our AI decision ledger and agentic SaaS architecture notes, or talk to us via custom software development when you need the gates built into your runtime rather than documented as aspirational policy.

