Agent tool failure testing: catch unsafe retries early
Agent tool failure testing needs more than error assertions. Use a practical matrix for expired credentials, denials, bad data, timeouts, and SSH interruptions.

Agent tool failure testing should focus on what the agent believes after an operation fails, not merely whether the tool emitted an error. A tool that reports "request failed" after a remote write may have completed creates a worse problem than a tool that stops with an incomplete answer. Agents will plan around whatever result you give them.
The happy path hides the decisions that decide whether an autonomous run is safe: retry, request approval, rotate access, repair data, or stop. I have seen tool suites with hundreds of green tests that had never forced a network drop between a remote command starting and its output returning. Those suites did not test the dangerous part.
A failure response is an input to the agent's planner
An agent treats a tool result as evidence. If the result implies that no change occurred, the agent may retry. If it implies that a credential expired, the agent may seek an authorized recovery path. If it implies success when the remote outcome is unknown, the agent may build several later actions on fiction.
Separate failures by what the caller can know. This distinction gets blurred constantly:
- A confirmed refusal means the remote service received the request and rejected it.
- A confirmed failure means the remote service returned a result that says it did not perform the requested work.
- An uncertain outcome means the caller cannot establish whether the remote side performed the work.
- A local failure means the tool failed before it could make a meaningful remote attempt.
A connection refused before a TCP session opens is usually local failure. An HTTP 403 is a confirmed refusal. A read timeout after sending a POST is uncertain unless the remote service gives you a way to look up the operation. These labels should appear in your test cases and your tool's result schema. Do not bury them in a sentence that an agent must interpret.
A compact result shape makes the contract testable:
{
"ok": false,
"category": "outcome_unknown",
"operation": "create_deployment",
"retry": "reconcile_first",
"correlation_id": "case-ssh-017",
"message": "Connection closed after the remote command started; remote completion is unknown."
}
The names do not matter much. The separation does. retry: "never" for a permission refusal and retry: "reconcile_first" for a timed-out write tell the agent different things without turning the entire error message into a prompt.
Do not return raw provider errors as the only interface. They change, often include irrelevant prose, and sometimes contain request material you should not hand to an agent. Preserve the original status, body, and headers in protected diagnostics. Return a stable, deliberately small result to the caller.
Build the matrix around operations and evidence
A useful matrix crosses each operation with the failure modes that can change its meaning. Start by listing tools that read, create, update, delete, trigger, or execute. A read that times out has a different recovery rule than a command that changes a production host.
Use this matrix as a starting artifact. Replace the operation names and expected records with your own, but do not delete the "remote effect known" column. That column forces the uncomfortable cases into the open.
| Case | Operation | Injected condition | Remote effect known? | Expected category | Agent instruction |
|---|---|---|---|---|---|
| C01 | read issue | DNS lookup fails | yes, no request sent | local_failure | retry within a bounded budget |
| C02 | create issue | token expired | yes, rejected | authentication_failed | stop and request authorized credential recovery |
| C03 | delete release | permission denied | yes, rejected | authorization_denied | do not retry |
| C04 | read build | JSON has status: 7 | yes, response received | malformed_response | stop and report schema mismatch |
| C05 | create deployment | response delayed past client deadline | no | outcome_unknown | reconcile before retry |
| C06 | run SSH restart | local helper terminated after remote start | no | outcome_unknown | inspect remote state before another command |
| C07 | update record | service returns 429 | yes, rejected | rate_limited | wait as instructed, then retry if safe |
Add rows for the operations that spend money, alter permissions, rotate credentials, or affect shared state. Those operations need more than one timeout row. Test a timeout before bytes leave the process, after request headers leave, after the service accepts the request, and while a response body arrives. The exact injection points depend on your protocol, but collapsing them into one generic "timeout" case loses the behavior you need to verify.
Every row needs four assertions:
- Assert the tool result category and retry instruction.
- Assert the agent's next action, including that it does not improvise a destructive retry.
- Assert the remote state or the documented reason it remains unknown.
- Assert the event trail contains the correlation ID and the observed outcome.
This is more work than asserting ok == false. It also catches the failures that matter after an agent has already taken several actions.
Expired credentials and denied actions require different recovery
An expired or revoked credential proves that authentication failed. A denied action proves that the caller authenticated but lacks permission for that operation, unless a provider deliberately obscures the distinction. Treating both as "access failed" produces bad agent behavior.
RFC 9110 defines 401 as an unauthenticated request and requires the server to send a WWW-Authenticate challenge. It defines 403 as a refusal to fulfill the request, even when the server does not disclose why. Providers do not always follow that distinction cleanly, so test the provider's actual response. Still, your tool should map its observed evidence into separate categories where it can do so honestly.
Make an expired credential test use a credential that was accepted during setup and is rejected during the actual call. A fake string that the service never recognized only tests an invalid credential branch. You want to catch caches, refresh code, and error mappers that behave differently once an access token has aged out.
A simple fixture can express both cases without exposing a secret:
cases:
- id: expired-token
request:
method: POST
path: /v1/releases
fixture_response:
status: 401
headers:
www-authenticate: Bearer error="invalid_token"
body: {"error":"token_expired"}
expect:
category: authentication_failed
retry: never
secret_in_result: false
- id: denied-release
request:
method: POST
path: /v1/releases
fixture_response:
status: 403
body: {"error":"insufficient_scope"}
expect:
category: authorization_denied
retry: never
secret_in_result: false
The secret_in_result assertion catches a mistake that shows up during frantic debugging: code attaches the outgoing authorization header or configuration object to an exception. Test serialized tool output, trace output, and whatever transcript reaches the agent. Redaction in one logger does not protect another logger.
Do not make the agent "try another credential" unless your system explicitly gives it a distinct, authorized identity to use. Blind credential selection can cross a privilege boundary while appearing to solve an availability problem. A test should prove that an auth failure stops the run or routes it to the approved human recovery path.
Malformed data needs a contract test, not a JSON parser test
Malformed data includes valid JSON that your code cannot safely use. Invalid syntax is the easy case. Production failures more often arrive as a field changing type, a required identifier disappearing, an error envelope replacing a success envelope, or a response truncated after a proxy closes a connection.
The JSON-RPC 2.0 specification separates parse errors (-32700) from invalid requests (-32600). That split is useful because it distinguishes unreadable bytes from a readable message that violates the protocol. Apply the same discipline to your domain responses: a parser success does not prove the response satisfies the tool contract.
For every provider response you consume, write fixtures that violate one assumption at a time:
- Replace a string ID with
null, a number, and an object. - Omit a field that later tool calls require for reconciliation.
- Return a success status with an error-shaped body.
- Return an error status with an HTML body or a truncated JSON document.
- Duplicate an item or change ordering when your code selects the first item.
Then assert the exact behavior. The tool should name the failing field or contract condition in protected diagnostics, return malformed_response to the agent, and perform no follow-on mutation based on guessed values.
A common bad pattern looks harmless: response.id || request.id. It keeps a workflow moving when a provider omits id, but it may cause an update or delete against an unrelated object if request identity and response identity differ. Test that missing response identity stops the operation. A failed workflow is cheaper than an incorrect write.
MCP tool clients need the same care. The Model Context Protocol tool result format supports an isError signal for a tool-level failure. Use it when the tool itself cannot complete its promised work, but keep the result content specific enough for an agent to choose a safe branch. Do not disguise a malformed upstream response as a normal text result that begins with "Error:". Many clients will treat that as successful tool execution and leave the agent to infer the rest.
Timeouts are ambiguous after a write begins
A timeout tells you that your deadline expired. It does not identify the state of the remote operation. This sounds obvious until a retry loop quietly turns a lost response into a duplicate invoice, two deployments, or a second restart.
Test timeout behavior at the boundary where certainty changes. Your fault injector or fake service should record each stage:
case=C05 request_id=case-http-005 received=true
case=C05 request_id=case-http-005 mutation_committed=true
case=C05 response_write=delayed
client case=C05 deadline_exceeded=true
The expected assertion is not "client got timeout." The assertion is that the client returns outcome_unknown, does not issue a second POST, and uses a status lookup or idempotency mechanism before proceeding.
Idempotency tokens help only when the remote API documents and honors them for the operation in question. Test them as a full sequence: submit a request with a unique token, delay the first response until the caller gives up, submit the same token through the recovery path, then verify the service reports one logical operation. Do not claim idempotency because you added a header that a provider ignores.
For operations without a reconciliation endpoint or idempotency support, say so in the tool result. The safe action may be to stop and ask a person to inspect the remote system. That is not an engineering failure. Pretending certainty because a workflow wants to continue is one.
Set timeouts by phase where your client supports it: connection, request write, first response byte, and total operation duration. A single large deadline hides whether a peer never accepted a connection or accepted a write then stalled. Your tests do not need to expose every phase to the agent, but your diagnostics need enough detail for an operator to reproduce the event.
Interrupted remote commands must preserve uncertainty
An SSH command has a failure window that HTTP developers often underestimate. The client may send the command, the remote shell may start it, and then the connection may close before the caller receives an exit status. A local process crash or a lost network route does not undo work that the remote host already started.
OpenSSH documents that its client returns the remote command's exit status when it can obtain one. When the transport breaks first, the caller does not have that status. Test that case deliberately rather than treating a nonzero local process exit as proof that the remote command failed.
Create a remote test command that records a start marker, waits, records a completion marker, and writes a recognizable result. Then terminate the local transport while the wait runs. Keep this confined to an isolated host or container you own.
# remote command used only in an isolated test environment
id="case-ssh-017"
printf '%s start\n' "$id" >> /tmp/agent-tool-test.log
sleep 20
printf '%s complete\n' "$id" >> /tmp/agent-tool-test.log
Run the command through the same SSH path that the tool uses, wait until the start marker appears, then terminate the local helper. After the remote wait has elapsed, inspect the log. Run the test twice: once where the remote process completes, and once where the remote side kills it after the start marker. Both produce a local interruption, yet they demand different recovery.
For commands that change state, design a reconciliation command before you design a retry. A service restart might query process uptime or a deployment revision. A package installation might query the installed version. A command that cannot be reconciled should require explicit human handling after interruption.
Avoid shell snippets that hide partial completion behind && chains and vague output. Emit a durable operation ID before the mutating part begins, and use that ID in later inspection. If the remote environment cannot retain any marker, the tool has no basis to tell an agent that retrying is safe.
Human denials are a normal result, not a broken test
A human declining an action should produce a distinct result that ends that action branch cleanly. Teams often test that an approval screen appears and forget to test the decline path, which leaves agents retrying, rephrasing the same request, or misreporting an approval failure as a network issue.
Test denial at each authorization boundary you expose. Verify that the tool does not connect to the remote service after a denial. Verify that it does not retain an approval for a later process or a later action that needs a fresh decision. Verify that the agent gets language it can use without treating the refusal as an invitation to find a workaround.
Sallyport's vault gate denies every action while locked, and its session and per-call approvals make those decisions testable without placing credentials in the agent process. That division is useful because a locked vault, a declined session, and a denied per-call use can all stop an operation for different reasons.
Approval fatigue is a test failure in its own right. If a harmless read generates repeated prompts during a normal run, people will approve without reading. If a destructive call inherits a broad approval accidentally, people never get the decision point they expected. Test the number, timing, and scope of prompts along with their presence.
Use a test agent that tries one approved action, one denied action, and one action after the process exits. The final call catches approval state that leaks past the intended session. Do not simulate this only by toggling an in-memory Boolean; launch a fresh process so the test shares the lifecycle your users actually run.
Logs must answer what happened without exposing access
A useful failure record lets you reconstruct causality: which agent run attempted which operation, what request identifier it used, what the remote system observed, what the tool returned, and what the agent did next. It should not need to contain the credential that authorized the call.
Log an event at every point where the answer can change. For a timed write, capture request construction, dispatch start, remote acceptance if your fixture can report it, deadline expiry, reconciliation attempt, and final classification. Include a correlation ID generated before the first network action. Do not derive it from a secret or reuse it across operations.
This record format works for a local test harness:
{"time":"2025-04-12T10:18:03Z","case":"C05","id":"case-http-005","event":"dispatch_started"}
{"time":"2025-04-12T10:18:03Z","case":"C05","id":"case-http-005","event":"remote_committed"}
{"time":"2025-04-12T10:18:08Z","case":"C05","id":"case-http-005","event":"client_timeout"}
{"time":"2025-04-12T10:18:08Z","case":"C05","id":"case-http-005","event":"result","category":"outcome_unknown"}
Your assertion can then compare client and fixture records by id. If the fixture says remote_committed and the tool says confirmed_failure, fail the test. That disagreement exposes an unsafe claim, even if all code paths returned a neat error object.
For a tamper-evident trail, test verification too. Sallyport projects its session and activity records from an encrypted hash-chained audit log, and sp audit verify checks the chain offline without needing a vault key. A failure test should add a known event sequence, verify it, alter a copied record, and assert that verification fails on the altered copy.
Do not put full request bodies into ordinary logs by default. Request data often carries personal data, source code, or tokens embedded by a careless caller. Log the operation name, target classification, correlation ID, result category, and a protected diagnostic reference. Expand collection only in a controlled test environment where you know what the fixtures contain.
Test the agent's recovery behavior, not only the adapter
Unit tests prove that an adapter maps 403 to authorization_denied. They do not prove that an agent stops after receiving it. Run a small end-to-end suite with a deterministic agent instruction and a fake remote service that exposes the injected event log.
Give each run a narrow task and an explicit boundary. For example: create one record, read it back, then attach a note. Delay the create response after committing the record. The correct agent behavior is to inspect by correlation ID or idempotency token before it attempts a second create. A test should fail if it creates another record, even if it eventually completes the task.
Keep the agent prompt stable for this suite. If you change the prompt, tool contract, fixture behavior, and model version at once, a failure tells you little. Record the tool transcript and the agent's next call, then compare it against permitted transitions:
create -> outcome_unknown -> lookup_by_request_id -> found -> attach_note
create -> outcome_unknown -> create
The first transition is permitted only if the lookup confirms the original create. The second is a failure. This state-machine check is more useful than judging whether the final prose response sounded sensible.
Run deterministic matrix cases on every change to tool code, result schemas, authorization handling, or retry logic. Run interruption cases repeatedly in isolated infrastructure because scheduling affects them. When a new incident appears, add the smallest reproduction to the matrix before you fix it. Otherwise the same attractive, wrong recovery path will return during the next refactor.
The standard for a tool is not that it keeps moving after every fault. The standard is that it tells the truth about what it knows, leaves evidence behind, and refuses to turn uncertainty into a second destructive action.
FAQ
What should a failure test matrix for AI agent tools include?
A useful matrix crosses operation type with failure type and expected client behavior. Include the request, injected fault, transport evidence, expected tool result, retry rule, and expected audit record. If a row cannot say whether the remote side may have changed, it is not ready to automate.
How do I test expired API credentials for an agent?
Treat an expired credential as an authentication failure whose recovery path requires a new credential, not a blind retry. Test the actual response your provider returns, including headers and body shape, because one service may use 401 while another returns a provider-specific error. Confirm that the agent never receives the replacement secret while it handles the result.
What is the difference between an expired credential and a denied action?
A denied action means the identity may be valid but the requested operation is not allowed. Test it separately from an expired credential because the correct behavior differs: stop the operation, report the refusal, and do not ask for secret rotation. A retry can turn a clean denial into pointless traffic or approval noise.
How should I test malformed API responses from a tool?
Return syntactically valid JSON with the wrong types, missing required fields, or a changed error envelope. Parsers that only test invalid JSON miss the failures that often reach production. The tool should reject unusable data with a specific error and should not invent missing identifiers or status values.
Is it safe to retry an agent tool call after a timeout?
No. A timeout only says that the caller stopped waiting. The remote service may have completed the request, may still be running it, or may never have received it. Test all three possibilities and require reconciliation before retrying a mutating operation.
How can I simulate an interrupted SSH command?
Kill the local SSH helper after the remote command has begun and preserve the remote command's output somewhere your test can inspect. This creates the ambiguity that ordinary connection-refused tests avoid. The agent should report an unknown outcome rather than claim that the command failed or succeeded.
How do I test a user denying an agent action?
Test approval denial as its own branch, with a human declining the request at the approval boundary. The agent should receive a clear denial result, stop its current branch, and avoid repeated prompts for the same action. A test that only checks whether the dialog appeared does not prove any of that.
What logs do I need to debug tool failures?
Use unique correlation IDs and collect records from the client, the fault injector, and the remote service. Compare the attempted request, the observed remote effect, the returned result, and the agent's next action. Logs that only record the final error cannot resolve an interrupted write.
Should agent tools return one generic error for all failures?
No. A generic catch block hides whether the tool saw a refusal, a bad response, a connection failure, or an uncertain write. Give the agent a small stable error vocabulary, then retain detailed diagnostics in logs for the operator. That split keeps planning sane without erasing evidence.
How often should I run failure tests for agent tools?
Run deterministic failure cases in every change set, then run timing and interruption cases repeatedly in an isolated environment. Concurrency bugs and partial writes need repetition because the schedule matters. Do not wait for a production incident to discover that the tool reports certainty it does not have.