Handling semantic failures
What happens when a step's output is rejected
A semantic failure is what happens when your agent produces something and one of Parlant's checks says no. A tool review can reject tool calls. A message review can reject a drafted reply. Moderation can block a reply on its way out. Each time, the agent tries again with a correction, up to a limit. Then Parlant gives up and tells the customer it could not help, unless your code takes over.
- The three checks that can reject a step
- How many retries a turn gets at each effort level
- What Parlant does when it gives up, and the three reasons it can
- How your own code can take over the give-up response
A semantic failure is not the end of the turn. But it is not free to repeat forever, either. This page covers what Parlant does with one, how many it tolerates, and what happens when it runs out of patience.
Where semantic failures come from
| Source | What was rejected | What Parlant does |
|---|---|---|
| Tool review | Proposed tool calls | Discards the calls unrun and corrects the reasoning |
| Message review | A drafted reply | Discards the draft and corrects the reasoning |
| Moderation | A reply on its way out | Feeds back the reason (never the text) as guidance |
In every case, the step then restarts. The next attempt never sees the rejected output, only the corrected reasoning, written as if the agent had reasoned correctly from the start.
Retrying, and running out of retries
Here is how a step plays out once one of those checks is in the picture:
Each restart spends from a budget that grows with the agent's effort level:
| Effort | Max restarts |
|---|---|
MIN | 1 (reviews do not run at MIN anyway) |
LOW | 2 |
MEDIUM (default) | 3 |
HIGH | 5 |
MAX | 10 |
Note that this budget is shared across everything that can reject a step: tool review, message review, and moderation. Three rejections of any mix, at MEDIUM, and the turn is out.
These numbers are defaults, not limits. The budget lives on the engine's optimization policy, and your coding agent can override it per effort level. Overriding engine behavior shows how.
Giving up
When the budget runs out, Parlant gives up on the task, deliberately. It runs one final step with tools disabled and the matched rules stripped out. It tells the model to explain to the customer, concisely, that it could not help right now. It will not deliver output that repeatedly failed review, nor claim that the request was completed.
This final message is not reviewed. A rejection there would have nowhere to go, and the customer would get silence. If agent-side moderation flags the final message, Parlant emits a fixed fallback string instead. Moderating messages shows how to set that string.
Running out of retries is not the only way a turn gives up. A turn gives up for one of three reasons, and Parlant tells your code which one it was through payload.kind, a p.CompassHookPayload.GiveUpKind:
GiveUpKind | Means |
|---|---|
RETRIES_EXHAUSTED | Output was rejected by review, or blocked by moderation, once too often. |
MAX_STEPS | The turn used up the agent's max_steps without preparing a reply. |
HOOK_REQUESTED | One of your review hooks rejected a step and asked to give up right away. |
Taking over what happens on give-up
The apology is not the only possible outcome. Before the final step runs, Parlant calls the on_giving_up hook with the reason, the kind, and the number of steps taken. Return RESOLVE from your handler and Parlant generates nothing of its own. Your handler owns the response, whether that is a canned message, a handoff to a person, or silence:
async def hand_off_instead_of_apologizing(
ctx: p.EngineContext,
payload: p.CompassHookPayload.GaveUp,
exc: Exception | None,
) -> p.CompassHookResult:
await p.Session.current.update(mode="manual") # a person owns this conversation now
await ctx.session_event_emitter.emit_message_event(
trace_id=ctx.tracer.trace_id,
data="I can't sort this one out myself, so a colleague will pick it up right here.",
)
return p.CompassHookResult.RESOLVE
hooks.on_giving_up.append(hand_off_instead_of_apologizing)
Return CALL_NEXT instead and Parlant's own message still goes out, which is what you want from a handler that only records the event.
Hooking into the engine covers registering hooks and the full set of hook points.