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?;
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { ActionRequired } from '../api';
import { defineMessages, useIntl } from '../i18n';
import { Button } from './ui/button';
import JsonSchemaForm from './ui/JsonSchemaForm';
import type { JsonSchema } from './ui/JsonSchemaForm';
@@ -25,6 +26,10 @@ const i18n = defineMessages({
id: 'elicitationRequest.submit',
defaultMessage: 'Submit',
},
accept: {
id: 'elicitationRequest.accept',
defaultMessage: 'Accept',
},
waitingForResponse: {
id: 'elicitationRequest.waitingForResponse',
defaultMessage: 'Waiting for your response ({timeRemaining} remaining)',
@@ -79,11 +84,19 @@ export default function ElicitationRequest({
const { id: elicitationId, message, requested_schema } = actionRequiredContent.data;
const schema = (requested_schema ?? {}) as JsonSchema;
const hasSchemaFields = Boolean(schema.properties && Object.keys(schema.properties).length > 0);
const handleSubmit = (formData: Record<string, unknown>) => {
setSubmitted(true);
onSubmit(elicitationId, formData);
};
const handleAccept = () => {
setSubmitted(true);
onSubmit(elicitationId, {});
};
if (isCancelledMessage) {
return (
<div className="goose-message-content bg-background-secondary rounded-2xl px-4 py-2 text-text-primary">
@@ -147,11 +160,19 @@ export default function ElicitationRequest({
</div>
</div>
<div className="goose-message-content bg-background-primary border border-border-primary dark:border-gray-700 rounded-b-2xl px-4 py-3">
<JsonSchemaForm
schema={requested_schema as JsonSchema}
onSubmit={handleSubmit}
submitLabel={intl.formatMessage(i18n.submit)}
/>
{hasSchemaFields ? (
<JsonSchemaForm
schema={schema}
onSubmit={handleSubmit}
submitLabel={intl.formatMessage(i18n.submit)}
/>
) : (
<div className="flex gap-2">
<Button type="button" onClick={handleAccept}>
{intl.formatMessage(i18n.accept)}
</Button>
</div>
)}
<div
className={`mt-3 pt-3 border-t border-border-primary flex items-center gap-2 text-sm ${isUrgent ? 'text-red-500' : 'text-text-secondary'}`}
>
@@ -169,7 +190,11 @@ export default function ElicitationRequest({
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{intl.formatMessage(i18n.waitingForResponse, { timeRemaining: formatTime(timeRemaining) })}</span>
<span>
{intl.formatMessage(i18n.waitingForResponse, {
timeRemaining: formatTime(timeRemaining),
})}
</span>
</div>
</div>
</div>
+6 -1
View File
@@ -117,7 +117,12 @@ export default function GooseMessage({
{thinkingContent && (
<ThinkingContent
content={thinkingContent}
isExpanded={isStreaming && !displayText.trim() && imagePaths.length === 0 && toolRequests.length === 0}
isExpanded={
isStreaming &&
!displayText.trim() &&
imagePaths.length === 0 &&
toolRequests.length === 0
}
/>
)}
@@ -283,7 +283,12 @@ export default function ProgressiveMessageList({
{/* Loading indicator when progressively rendering */}
{isLoading && (
<div className="flex flex-col items-center justify-center py-8">
<LoadingGoose message={intl.formatMessage(i18n.loadingMessages, { renderedCount, totalCount: messages.length })} />
<LoadingGoose
message={intl.formatMessage(i18n.loadingMessages, {
renderedCount,
totalCount: messages.length,
})}
/>
<div className="text-xs text-text-secondary mt-2">
{intl.formatMessage(i18n.searchHint)}
</div>
@@ -82,6 +82,7 @@ export default function JsonSchemaForm({
const [formData, setFormData] = useState<Record<string, unknown>>(() => {
const initial: Record<string, unknown> = {};
if (schema.properties) {
const isRequired = (key: string) => schema.required?.includes(key) ?? false;
for (const [key, prop] of Object.entries(schema.properties)) {
if (prop.default !== undefined) {
initial[key] = prop.default;
@@ -89,6 +90,8 @@ export default function JsonSchemaForm({
initial[key] = false;
} else if (prop.type === 'number' || prop.type === 'integer') {
initial[key] = prop.minimum ?? 0;
} else if (prop.enum && prop.enum.length > 0 && isRequired(key)) {
initial[key] = prop.enum[0];
} else {
initial[key] = '';
}
+17 -6
View File
@@ -897,15 +897,26 @@ export function useChatStream({
return;
}
// An elicitation response unblocks an in-flight tool call on the original
// request's SSE stream — don't start a new stream or flip chat state.
const responseMessage = createElicitationResponseMessage(elicitationId, userData);
const currentMessages = [...currentState.messages, responseMessage];
const nextMessages = [...currentState.messages, responseMessage];
dispatch({ type: 'SET_MESSAGES', payload: nextMessages });
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
dispatch({ type: 'START_STREAMING' });
await submitToSession(sessionId, responseMessage, currentMessages);
try {
await sessionReply({
path: { id: sessionId },
body: {
request_id: uuidv7(),
user_message: responseMessage,
},
throwOnError: true,
});
} catch (error) {
onFinish('Submit error: ' + errorMessage(error));
}
},
[sessionId, submitToSession]
[sessionId, onFinish]
);
const setRecipeUserParams = useCallback(
+3
View File
@@ -899,6 +899,9 @@
"dirSwitcher.failedToUpdateWorkingDir": {
"defaultMessage": "Failed to update working directory"
},
"elicitationRequest.accept": {
"defaultMessage": "Accept"
},
"elicitationRequest.cancelled": {
"defaultMessage": "Information request was cancelled."
},