zeroclaw-labs/zeroclaw · error · anyhow::Error
refusing to dispatch to provider: prepared history has no us
Error message
refusing to dispatch to provider: prepared history has no user turn (system-only after leading-turn-order sanitize)
What it means
prepare_messages_for_iteration enforces the universal leading-turn-order invariant before any provider dispatch: strict providers reject a first non-system turn that is not user, so leading assistant/tool-call turns are sanitized away. If after sanitization no user message remains at all (system-only history), the runtime fails closed — it refuses the provider call rather than sending a request the provider will reject opaquely.
Source
Thrown at crates/zeroclaw-runtime/src/agent/turn/vision_route.rs:130
None
};
Ok((vision_model_provider, degrade_strip_images))
}
pub(crate) async fn prepare_messages_for_iteration(
history: &[ChatMessage],
multimodal_config: &MultimodalConfig,
degrade_strip_images: bool,
image_cache: Option<&mut multimodal::LocalImageCache>,
) -> Result<multimodal::PreparedMessages> {
// Enforce the universal leading-turn-order invariant before any provider
// sees the history: strict providers reject a first non-system turn that is
// not `user`, which context trims and session restores can produce.
let mut sanitized = history.to_vec();
ChatMessage::sanitize_leading_turn_order(&mut sanitized);
if !sanitized.iter().any(ChatMessage::is_user) {
anyhow::bail!(
"refusing to dispatch to provider: prepared history has no user turn \
(system-only after leading-turn-order sanitize)"
);
}
let history = sanitized.as_slice();
if degrade_strip_images {
// Text-only fallback: replace every media marker with a
// `[media attachment]` placeholder so no filesystem path or data
// URI reaches the text-only provider, while surrounding text
// (captions, tool metadata) survives.
let stripped: Vec<ChatMessage> = history
.iter()
.map(|m| ChatMessage {
role: m.role.clone(),
content: multimodal::strip_media_markers(&m.content),
})
.collect();
match image_cache {View on GitHub (pinned to 88bb9c8533)
Solutions
- Replay the prepared history and confirm at least one ChatMessage::is_user survives trimming
- Adjust the trim policy to always retain the original (or a synthetic) user turn
- When restoring sessions, re-seed a user message summarizing the request before resuming the loop
- Fix upstream builders that construct system-only histories
Example fix
// before: trim policy can drop the only user turn history.retain(|m| !m.is_old(cutoff)); // after: always keep the initial user turn history.retain(|m| !m.is_old(cutoff) || m.is_initial_user_turn());
Defensive patterns
Strategy: validation
Validate before calling
let mut history = build_history(session);
if !history.iter().any(ChatMessage::is_user) {
history.push(ChatMessage::user(session.request_summary())); // re-seed a user turn
}
let resp = agent.run_tool_call_loop(history, ...).await?; Type guard
fn history_has_user_turn(history: &[ChatMessage]) -> bool {
history.iter().any(ChatMessage::is_user)
} Try / catch
match agent.run_turn(req).await {
Err(ref e) if e.to_string().contains("no user turn") => {
// history is structurally invalid: re-seed a user turn and rebuild the session
}
other => other,
} Prevention
- Configure trim policies to always retain the initial user turn
- Validate restored sessions before resuming: at least one user message must survive
- Never synthesize system-only histories for provider dispatch
- Test trimming with adversarial long sessions
When it happens
Trigger: Context trimming drops the last remaining user turn; a restored session contains only system + assistant/tool messages; histories built upstream without any user role. Tests prepare_fails_closed_when_no_user_turn_survives and prepare_strips_leading_assistant_tool_call pin exactly these shapes.
Common situations: Aggressive context-window trimming on long sessions; hand-built or migrated histories missing user turns; session restore after crashes; summarization pipelines that replace the user turn with a system summary and trim the original.
Related errors
- webhook-audit: {e}
- amqp.{}: dispatch = {:?} routes to the SOP engine but no SOP
- webhook channel requires a `secret` configured for request a
- run {run_id} is parked at an approval gate; amend/revise app
- Browser tool is enabled but no allowed_domains are configure
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/d0c38cb985115370.
Report an issue: GitHub.