fix: 8531 - elicitation fixes (#8999)
Signed-off-by: Alex Hancock <alexhancock@block.xyz>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] = '';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -899,6 +899,9 @@
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Failed to update working directory"
|
||||
},
|
||||
"elicitationRequest.accept": {
|
||||
"defaultMessage": "Accept"
|
||||
},
|
||||
"elicitationRequest.cancelled": {
|
||||
"defaultMessage": "Information request was cancelled."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user