Fix MCP elicitation deadlock and improve UX (#6650)
This commit is contained in:
@@ -1288,7 +1288,7 @@ impl Agent {
|
|||||||
let mut combined = stream::select_all(with_id);
|
let mut combined = stream::select_all(with_id);
|
||||||
let mut all_install_successful = true;
|
let mut all_install_successful = true;
|
||||||
|
|
||||||
while let Some((request_id, item)) = combined.next().await {
|
loop {
|
||||||
if is_token_cancelled(&cancel_token) {
|
if is_token_cancelled(&cancel_token) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1297,43 +1297,55 @@ impl Agent {
|
|||||||
yield AgentEvent::Message(msg);
|
yield AgentEvent::Message(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
match item {
|
tokio::select! {
|
||||||
ToolStreamItem::Result(output) => {
|
biased;
|
||||||
let output = call_tool_result::validate(output);
|
|
||||||
|
|
||||||
// Platform extensions use meta as a way to publish notifications. Ideally we'd
|
tool_item = combined.next() => {
|
||||||
// send the notifications directly, but the current plumbing doesn't support that
|
match tool_item {
|
||||||
// well:
|
Some((request_id, item)) => {
|
||||||
if let Ok(ref call_result) = output {
|
match item {
|
||||||
if let Some(ref meta) = call_result.meta {
|
ToolStreamItem::Result(output) => {
|
||||||
if let Some(notification_data) = meta.0.get("platform_notification") {
|
let output = call_tool_result::validate(output);
|
||||||
if let Some(method) = notification_data.get("method").and_then(|v| v.as_str()) {
|
|
||||||
let params = notification_data.get("params").cloned();
|
|
||||||
let custom_notification = rmcp::model::CustomNotification::new(
|
|
||||||
method.to_string(),
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
|
|
||||||
let server_notification = rmcp::model::ServerNotification::CustomNotification(custom_notification);
|
if let Ok(ref call_result) = output {
|
||||||
yield AgentEvent::McpNotification((request_id.clone(), server_notification));
|
if let Some(ref meta) = call_result.meta {
|
||||||
|
if let Some(notification_data) = meta.0.get("platform_notification") {
|
||||||
|
if let Some(method) = notification_data.get("method").and_then(|v| v.as_str()) {
|
||||||
|
let params = notification_data.get("params").cloned();
|
||||||
|
let custom_notification = rmcp::model::CustomNotification::new(
|
||||||
|
method.to_string(),
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
let server_notification = rmcp::model::ServerNotification::CustomNotification(custom_notification);
|
||||||
|
yield AgentEvent::McpNotification((request_id.clone(), server_notification));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enable_extension_request_ids.contains(&request_id)
|
||||||
|
&& output.is_err()
|
||||||
|
{
|
||||||
|
all_install_successful = false;
|
||||||
|
}
|
||||||
|
if let Some(response_msg) = request_to_response_map.get(&request_id) {
|
||||||
|
let metadata = request_metadata.get(&request_id).and_then(|m| m.as_ref());
|
||||||
|
let mut response = response_msg.lock().await;
|
||||||
|
*response = response.clone().with_tool_response_with_metadata(request_id, output, metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolStreamItem::Message(msg) => {
|
||||||
|
yield AgentEvent::McpNotification((request_id, msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
None => break,
|
||||||
|
|
||||||
if enable_extension_request_ids.contains(&request_id)
|
|
||||||
&& output.is_err()
|
|
||||||
{
|
|
||||||
all_install_successful = false;
|
|
||||||
}
|
|
||||||
if let Some(response_msg) = request_to_response_map.get(&request_id) {
|
|
||||||
let metadata = request_metadata.get(&request_id).and_then(|m| m.as_ref());
|
|
||||||
let mut response = response_msg.lock().await;
|
|
||||||
*response = response.clone().with_tool_response_with_metadata(request_id, output, metadata);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolStreamItem::Message(msg) => {
|
|
||||||
yield AgentEvent::McpNotification((request_id, msg));
|
_ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
|
||||||
|
// Continue loop to drain elicitation messages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { ActionRequired } from '../api';
|
import { ActionRequired } from '../api';
|
||||||
import JsonSchemaForm from './ui/JsonSchemaForm';
|
import JsonSchemaForm from './ui/JsonSchemaForm';
|
||||||
import type { JsonSchema } from './ui/JsonSchemaForm';
|
import type { JsonSchema } from './ui/JsonSchemaForm';
|
||||||
|
|
||||||
|
const ELICITATION_TIMEOUT_SECONDS = 300;
|
||||||
|
|
||||||
interface ElicitationRequestProps {
|
interface ElicitationRequestProps {
|
||||||
isCancelledMessage: boolean;
|
isCancelledMessage: boolean;
|
||||||
isClicked: boolean;
|
isClicked: boolean;
|
||||||
@@ -10,6 +12,12 @@ interface ElicitationRequestProps {
|
|||||||
onSubmit: (elicitationId: string, userData: Record<string, unknown>) => void;
|
onSubmit: (elicitationId: string, userData: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ElicitationRequest({
|
export default function ElicitationRequest({
|
||||||
isCancelledMessage,
|
isCancelledMessage,
|
||||||
isClicked,
|
isClicked,
|
||||||
@@ -17,6 +25,24 @@ export default function ElicitationRequest({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
}: ElicitationRequestProps) {
|
}: ElicitationRequestProps) {
|
||||||
const [submitted, setSubmitted] = useState(isClicked);
|
const [submitted, setSubmitted] = useState(isClicked);
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState(ELICITATION_TIMEOUT_SECONDS);
|
||||||
|
const startTimeRef = useRef(Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (submitted || isCancelledMessage || isClicked) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
|
||||||
|
const remaining = Math.max(0, ELICITATION_TIMEOUT_SECONDS - elapsed);
|
||||||
|
setTimeRemaining(remaining);
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [submitted, isCancelledMessage, isClicked]);
|
||||||
|
|
||||||
if (actionRequiredContent.data.actionType !== 'elicitation') {
|
if (actionRequiredContent.data.actionType !== 'elicitation') {
|
||||||
return null;
|
return null;
|
||||||
@@ -57,10 +83,39 @@ export default function ElicitationRequest({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUrgent = timeRemaining <= 60;
|
||||||
|
const isExpired = timeRemaining === 0;
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return (
|
||||||
|
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
|
||||||
|
<div className="flex items-center gap-2 text-textSubtle">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>This request has expired. The extension will need to ask again.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="goose-message-content bg-background-muted rounded-2xl rounded-b-none px-4 py-2 text-textStandard">
|
<div className="goose-message-content bg-background-muted rounded-2xl rounded-b-none px-4 py-2 text-textStandard">
|
||||||
{message || 'Goose needs some information from you.'}
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<span>{message || 'Goose needs some information from you.'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="goose-message-content bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 py-3">
|
<div className="goose-message-content bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 py-3">
|
||||||
<JsonSchemaForm
|
<JsonSchemaForm
|
||||||
@@ -68,6 +123,27 @@ export default function ElicitationRequest({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel="Submit"
|
submitLabel="Submit"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className={`mt-3 pt-3 border-t border-borderSubtle flex items-center gap-2 text-sm ${isUrgent ? 'text-red-500' : 'text-textSubtle'}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 animate-pulse"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
Waiting for your response ({formatTime(timeRemaining)} remaining)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user