Part of the v0.58.7 → HEAD TinyAgents-migration audit (see parent issue). Repairs tool-call failure classification (#4413 rework, commit b5837ed).
A. User approval denial classified as recoverable "try again"
The classifier short-circuits only POLICY_BLOCKED_MARKER ("[policy-blocked]") (src/openhuman/tinyagents/middleware.rs:1411-1424). The entire approval-gate deny family emits POLICY_DENIED_MARKER ("[policy-denied]", src/openhuman/security/policy/types.rs:26): user deny, dropped channel, subconscious-tainted origin, install_tool.rs:129/136, composio/tools.rs:802. The heuristic's intended needle "policy denied" (with a space, src/openhuman/tool_status/ops.rs:55) matches no producer — dead code.
Scenario: user clicks Deny on ApprovalRequestCard → classified Unknown → FailureCategory::Recoverable → UI: "Something went wrong with this action. Try again…" for an action the user just refused; Unknown is documented as eligible for one bounded auto-retry, i.e. auto-re-attempting a user-refused external effect.
B. Approval TTL expiry classified as execution Timeout promising auto-retry
Deny reason "[policy-denied] Approval for '{tool}' timed out after {N}s…" (src/openhuman/approval/gate.rs:703) hits the classifier's first check ("timed out", tool_status/ops.rs:34) → ToolFailureClass::Timeout → copy: "The action took too long and was stopped. OpenHuman will try again…". Nobody approved the action.
C. Backend never persists failure in the turn-state snapshot
ToolTimelineEntry has no failure field (src/openhuman/threads/turn_state/types.rs:58-73); turn_state/mirror.rs:138-153 destructures and drops it. The merged frontend expects it (app/src/types/turnState.ts:121-145, app/src/store/chatRuntimeSlice.ts:732-734): a failed tool's explanation survives live but disappears on thread switch/restart.
D. Smaller gaps
- Subagent failures are classified then discarded —
SubagentToolCallCompleted has no failure field (src/openhuman/tinyagents/observability.rs:518-545, src/openhuman/agent/progress.rs:179-193).
- Marker/timeout sniffs read
result.content while classify-text prefers result.error (middleware.rs:1413-1423) — latent asymmetry; and success == error.is_none() makes the unwrap_or(content) fallback dead.
Fix plan
- Add a
POLICY_DENIED_MARKER short-circuit before the heuristics → new class (e.g. ToolFailureClass::Denied) → FailureCategory::UserDeclined (non-retryable) with honest copy ("You declined this action" / "Approval expired before anyone responded").
- Check markers before the
"timed out" sniff so TTL expiry classifies as Denied/ApprovalExpired, never Timeout.
- Add
failure: Option<PersistedToolFailure> to ToolTimelineEntry + thread through mirror.rs, matching the shape in app/src/types/turnState.ts.
- Add
failure to SubagentToolCallCompleted and populate it from the already-computed classification.
- Normalize the classifier to sniff both
error and content consistently; delete the dead "policy denied" needle.
Acceptance criteria
- Deny → UI shows a "declined" explanation, no retry offer, no auto-retry eligibility. TTL expiry → "approval expired", no auto-retry promise.
- Failed-tool explanations survive thread switch/app restart (snapshot round-trip test against the frontend contract).
- Subagent failed rows carry an explanation.
Part of the v0.58.7 → HEAD TinyAgents-migration audit (see parent issue). Repairs tool-call failure classification (#4413 rework, commit b5837ed).
A. User approval denial classified as recoverable "try again"
The classifier short-circuits only
POLICY_BLOCKED_MARKER("[policy-blocked]") (src/openhuman/tinyagents/middleware.rs:1411-1424). The entire approval-gate deny family emitsPOLICY_DENIED_MARKER("[policy-denied]",src/openhuman/security/policy/types.rs:26): user deny, dropped channel, subconscious-tainted origin,install_tool.rs:129/136,composio/tools.rs:802. The heuristic's intended needle"policy denied"(with a space,src/openhuman/tool_status/ops.rs:55) matches no producer — dead code.Scenario: user clicks Deny on
ApprovalRequestCard→ classifiedUnknown→FailureCategory::Recoverable→ UI: "Something went wrong with this action. Try again…" for an action the user just refused;Unknownis documented as eligible for one bounded auto-retry, i.e. auto-re-attempting a user-refused external effect.B. Approval TTL expiry classified as execution Timeout promising auto-retry
Deny reason
"[policy-denied] Approval for '{tool}' timed out after {N}s…"(src/openhuman/approval/gate.rs:703) hits the classifier's first check ("timed out",tool_status/ops.rs:34) →ToolFailureClass::Timeout→ copy: "The action took too long and was stopped. OpenHuman will try again…". Nobody approved the action.C. Backend never persists
failurein the turn-state snapshotToolTimelineEntryhas nofailurefield (src/openhuman/threads/turn_state/types.rs:58-73);turn_state/mirror.rs:138-153destructures and drops it. The merged frontend expects it (app/src/types/turnState.ts:121-145,app/src/store/chatRuntimeSlice.ts:732-734): a failed tool's explanation survives live but disappears on thread switch/restart.D. Smaller gaps
SubagentToolCallCompletedhas nofailurefield (src/openhuman/tinyagents/observability.rs:518-545,src/openhuman/agent/progress.rs:179-193).result.contentwhile classify-text prefersresult.error(middleware.rs:1413-1423) — latent asymmetry; andsuccess == error.is_none()makes theunwrap_or(content)fallback dead.Fix plan
POLICY_DENIED_MARKERshort-circuit before the heuristics → new class (e.g.ToolFailureClass::Denied) →FailureCategory::UserDeclined(non-retryable) with honest copy ("You declined this action" / "Approval expired before anyone responded")."timed out"sniff so TTL expiry classifies as Denied/ApprovalExpired, never Timeout.failure: Option<PersistedToolFailure>toToolTimelineEntry+ thread throughmirror.rs, matching the shape inapp/src/types/turnState.ts.failuretoSubagentToolCallCompletedand populate it from the already-computed classification.errorandcontentconsistently; delete the dead"policy denied"needle.Acceptance criteria