Error Handling
September 7, 2026 · View on GitHub
Principles
- Custom exceptions in
acp/client.pyfor ACP-protocol errors, and inacp/session_handle.pyfor runtime/transport errors - Error strings at CLI boundaries (never expose tracebacks to users)
- Graceful degradation — partial output returned on timeout
Exception Hierarchy
Two independent families. AcpError covers protocol and prompt-level failures;
AcpRuntimeError covers the process and request transport underneath it.
AcpError (base, acp/client.py) — carries `transient`, the retry verdict
├── AcpTimeoutError — prompt timed out, has partial_output
├── AcpPermissionNeeded — tool approval required
├── AcpProcessDied — kiro-cli exited unexpectedly
├── AcpAuthRequired — kiro-cli not authenticated; non-retryable
├── AcpToolGateUnroutable — tool calls would bypass the PreToolUse gate;
│ non-retryable, wraps acp_tool_gate.ToolGateUnroutable
├── AcpModelUnavailable — requested model not entitled; non-retryable
└── AcpPromptBusy — a prompt is already in flight on this session
AcpRuntimeError (base, acp/session_handle.py)
├── AcpRuntimeDead — the underlying process has died
├── AcpRequestTimeout — a request's response missed its budget
└── AcpWorkspaceBindingError — a descriptor-bound runtime cannot serve another
cwd (acp/runtime.py)
AcpToolGateUnroutable is a distinct type rather than a transport error because
the condition is a configuration fact: a respawn re-reads the same answer and
refuses again while consuming a reconnect budget meant for transport faults. The
same argument makes AcpAuthRequired and AcpModelUnavailable distinct — each
one is invalid on its own terms, so the retry ladder must be skipped rather than
walked. AcpRequestTimeout subclasses its base so existing
except AcpRuntimeError handlers keep catching it.
Boundaries
| Boundary | Strategy |
|---|---|
| ACP → CLI | Catch AcpError, print user-friendly message, sys.exit(1) |
| JSON-RPC read | Non-JSON lines silently skipped (kiro-cli debug output) |
| Config load | Invalid JSON → log warning, return defaults |
| Process spawn | shutil.which check before spawn; clear error if missing |
| asyncio loop callback | A Windows Proactor reset repeated by its connection_lost close callback is warning-only; task-level connection resets and other exceptions remain ERRORs with crash breadcrumbs |
Backend Error Classification
acp/client.py rewrites raw JSON-RPC backend errors into actionable user text
(_format_acp_error) and decides retry-eligibility (_is_transient_raw_error).
Both key off the SAME module-level _RE_* patterns so wording and retry verdict
never drift. Notable terminal (non-retryable) classes:
- Malformed request: a structural rejection (backend "Improperly formed
request"). Classified TERMINAL: the identical payload cannot succeed on
retry, so the message states the request was malformed and points at a repair
affordance (
/compactto shrink and repair the conversation, or starting a new conversation) rather than suggesting a retry. The reset affordance is PROSE, not a command: this formatter does not know which surface renders the string, and the reset command differs per surface (/newon Telegram and Discord, a new tab on the dashboard), so naming one spelling hands every other surface's user a command that does nothing. A command may be named here only if every surface UNDERSTANDS it:/compactqualifies because it reaches the backend through the prompt transport everywhere, even on Slack, which also offers!compactas its own alias. The same rule governs the sibling prompt-busy branch, which for the same reason now names no command at all. - Usage limit and model not entitled: allowance spent, or the plan lacks the model; also terminal, with guidance to switch model or tier.
Model-Side Refusals
A refusal is a turn the model DECLINED, not a turn that failed: the request
reached the model and the answer is "no". It is deterministic — the same prompt
hits the same filter — so it is never retried, and the useful thing to show is
the reason. Harnesses report that reason unevenly, so acp/types.RefusalInfo
is the one shape every harness is folded onto (category,
explanation, recommended_model), each field left EMPTY when the provider
did not say — never guessed.
- Kiro (kiro-cli, KAS) — the service's content filter emits a
_kiro.dev/metadataframe withstopReason: CONTENT_FILTEREDand arefusalobject, streams the canned explanation ("The selected model cannot continue this conversation…") as ordinary assistant text, then ends the turn with a plainend_turn(or a bare-32603).acp/_dispatch.parse_refusalreads the frame (members ofACP_BACKENDS_STRUCTURED_REFUSALonly) ontoAcpPromptStats.refusal;AcpPromptStats.terminal_refusalrewrites the terminal's stop reason toSTOP_REASON_REFUSALand attaches the payload asAcpEvent.refusal. The explanation is redacted at the parser. - claude-agent-acp, codex-acp — only Anthropic's bare
stopReason: "refusal"reaches the client;terminal_refusalpasses it through with no payload, and the dashboard's refusal branch (keyed on the stop reason) renders the bare card. - Dashboard —
chat_runner.refusal_card_textrenders one card fromRefusalInfo: the lead line, then one line per non-empty field. Because the Kiro explanation streams as text, the card is emitted from BOTH the answered and the text-less branch of the turn epilogue.