zeroclaw-labs/zeroclaw · error · anyhow::Error

AcpChannel.request_multi_choice requires at least one choice

Error message

AcpChannel.request_multi_choice requires at least one choice

What it means

request_multi_choice rejects an empty choices slice before consulting capabilities. Note the ordering: the emptiness check runs first, so even a client without elicitation.form — which would otherwise receive Ok(None) so the poll tool can take its own non-ACP fallback — still gets an error on an empty list.

Source

Thrown at crates/zeroclaw-channels/src/acp_channel.rs:369

        if self.client_caps.form {
            self.request_choice_via_elicitation(question, choices, timeout)
                .await
        } else {
            self.request_choice_via_permission(question, choices, timeout)
                .await
        }
    }

    async fn request_multi_choice(
        &self,
        question: &str,
        choices: &[String],
        min_items: usize,
        max_items: usize,
        timeout: Duration,
    ) -> anyhow::Result<Option<Vec<String>>> {
        if choices.is_empty() {
            anyhow::bail!("AcpChannel.request_multi_choice requires at least one choice")
        }
        if !self.client_caps.form {
            // No legacy fallback for multi-select — session/request_permission
            // is single-select-only. Signal Ok(None) so the caller (poll tool)
            // takes its own non-ACP fallback path.
            return Ok(None);
        }

        let req = ElicitationRequest {
            session_id: self.session_id.clone(),
            mode: ElicitationMode::Form,
            message: question.to_string(),
            requested_schema: multi_select_schema(choices, min_items, max_items),
        };
        let params = serde_json::to_value(&req)?;
        let call = self.rpc.request("elicitation/create", params);
        let response_value = match tokio::time::timeout(timeout, call).await {
            Ok(Ok(value)) => value,

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Guard the call: if the list is empty, return early or skip the question rather than invoking request_multi_choice.
  2. Fix the upstream list producer so multi-select questions always have candidates.
  3. Remember the adjacent contract: Ok(None) from this API means capability-missing fallback, so keep the empty-list check separate from that path.

Example fix

// before
let picks = ch.request_multi_choice("Pick tags", &tags, 1, 3, timeout).await?;

// after
if tags.is_empty() {
    return Ok(None); // nothing to select; let the caller skip
}
let picks = ch.request_multi_choice("Pick tags", &tags, 1, 3, timeout).await?;
Defensive patterns

Strategy: validation

Validate before calling

fn ensure_multi_choices(choices: &[String]) -> anyhow::Result<()> {
    if choices.is_empty() {
        anyhow::bail!("multi-select requires at least one option");
    }
    Ok(())
}

Type guard

fn has_options(choices: &[String]) -> bool {
    !choices.is_empty()
}

Prevention

When it happens

Trigger: The poll tool or another caller builds a multi-select list dynamically and passes an empty slice to request_multi_choice, regardless of whether the client advertised elicitation.form.

Common situations: Tag/category pickers whose source list is empty after filtering; batch-selection prompts over an empty result set; refactors that drop the guard at the call site.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/84ff0bd00aaa4b9d. Report an issue: GitHub.