fix: 8531 - elicitation fixes (#8999)

Signed-off-by: Alex Hancock <alexhancock@block.xyz>
This commit is contained in:
Alex Hancock
2026-05-05 14:47:25 -04:00
committed by GitHub
parent dd95b7bb85
commit cadae27bb0
9 changed files with 128 additions and 30 deletions
+19 -3
View File
@@ -11,9 +11,25 @@ pub fn collect_elicitation_input(
println!("\n{}", style(message).cyan());
}
let properties = match schema.get("properties").and_then(|p| p.as_object()) {
Some(props) => props,
None => return Ok(Some(HashMap::new())),
let properties = schema.get("properties").and_then(|p| p.as_object());
// Schema-less (or empty-schema) elicitations are pure approval prompts —
// offer an explicit Y/N confirmation instead of silently auto-accepting.
let properties = match properties {
Some(props) if !props.is_empty() => props,
_ => {
let prompt = if message.is_empty() {
"Approve this action?"
} else {
"Approve?"
};
return match cliclack::confirm(prompt).initial_value(true).interact() {
Ok(true) => Ok(Some(HashMap::new())),
Ok(false) => Ok(None),
Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(None),
Err(e) => Err(e),
};
}
};
let required: Vec<&str> = schema
@@ -313,6 +313,38 @@ pub async fn session_reply(
}
}
let user_message = request.user_message;
let override_conversation = request.override_conversation;
// An elicitation response unblocks an in-flight tool call that is already
// streaming on another request_id — don't register a new active request or
// open a new SSE stream; route it to the agent's short-circuit path.
let is_elicitation_response = user_message.content.iter().any(|c| {
matches!(
c,
goose::conversation::message::MessageContent::ActionRequired(ar)
if matches!(
ar.data,
goose::conversation::message::ActionRequiredData::ElicitationResponse { .. }
)
)
});
if is_elicitation_response {
let agent = state.get_agent_for_route(session_id.clone()).await?;
let session_config = goose::agents::types::SessionConfig {
id: session_id.clone(),
schedule_id: session_data.schedule_id.clone(),
max_turns: None,
retry_config: None,
};
let _ = agent
.reply(user_message, session_config, None)
.await
.map_err(|e| ErrorResponse::internal(e.to_string()))?;
return Ok(Json(SessionReplyResponse { request_id }));
}
let bus = state.get_or_create_event_bus(&session_id).await;
let cancel_token = bus
@@ -322,9 +354,6 @@ pub async fn session_reply(
ErrorResponse::bad_request("Session already has an active request. Cancel it first.")
})?;
let user_message = request.user_message;
let override_conversation = request.override_conversation;
let task_state = state.clone();
let task_session_id = session_id.clone();
let task_request_id = request_id.clone();
+11 -10
View File
@@ -1042,18 +1042,19 @@ impl Agent {
if let ActionRequiredData::ElicitationResponse { id, user_data } =
&action_required.data
{
if let Err(e) = ActionRequiredManager::global()
// Surface stale/cancelled/timed-out elicitations as a hard
// error so callers (e.g. the HTTP handler) can propagate
// failure to the client instead of silently reporting
// success while the blocked tool call stays unblocked.
// The success path returns an empty stream; an Err here
// makes the contract: Ok(empty) on accept, Err on reject.
ActionRequiredManager::global()
.submit_response(id.clone(), user_data.clone())
.await
{
let error_text = format!("Failed to submit elicitation response: {}", e);
error!(error_text);
return Ok(Box::pin(stream::once(async {
Ok(AgentEvent::Message(
Message::assistant().with_text(error_text),
))
})));
}
.map_err(|e| {
error!("Failed to submit elicitation response: {}", e);
anyhow!("Failed to submit elicitation response: {}", e)
})?;
session_manager
.add_message(&session_config.id, &user_message)
.await?;