feat(mcp): elicitation support (#5965)
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
use console::style;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, BufRead, IsTerminal, Write};
|
||||
|
||||
pub fn collect_elicitation_input(
|
||||
message: &str,
|
||||
schema: &Value,
|
||||
) -> io::Result<Option<HashMap<String, Value>>> {
|
||||
if !message.is_empty() {
|
||||
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 required: Vec<&str> = schema
|
||||
.get("required")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut data: HashMap<String, Value> = HashMap::new();
|
||||
|
||||
for (name, field_schema) in properties {
|
||||
let is_required = required.contains(&name.as_str());
|
||||
let field_type = field_schema
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("string");
|
||||
let description = field_schema.get("description").and_then(|d| d.as_str());
|
||||
let default = field_schema.get("default");
|
||||
let enum_values = field_schema.get("enum").and_then(|e| e.as_array());
|
||||
|
||||
// makes a little true/false toggle
|
||||
if field_type == "boolean" {
|
||||
let label = match description {
|
||||
Some(desc) => format!("{} ({})", name, desc),
|
||||
None => name.clone(),
|
||||
};
|
||||
let default_bool = default.and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
match cliclack::confirm(&label)
|
||||
.initial_value(default_bool)
|
||||
.interact()
|
||||
{
|
||||
Ok(v) => {
|
||||
data.insert(name.clone(), Value::Bool(v));
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(options) = enum_values {
|
||||
let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect();
|
||||
println!(" {}: {}", style("Options").dim(), opts.join(", "));
|
||||
}
|
||||
|
||||
print!("{}", style(name).yellow());
|
||||
if let Some(desc) = description {
|
||||
print!(" {}", style(format!("({})", desc)).dim());
|
||||
}
|
||||
if is_required {
|
||||
print!("{}", style("*").red());
|
||||
}
|
||||
if let Some(def) = default {
|
||||
print!(" {}", style(format!("[{}]", format_default(def))).dim());
|
||||
}
|
||||
print!(": ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let input = read_line()?;
|
||||
|
||||
// Handle Ctrl+C / EOF for cancellation
|
||||
if input.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let input = input.unwrap();
|
||||
|
||||
let value = if input.is_empty() {
|
||||
default.cloned()
|
||||
} else {
|
||||
Some(parse_value(&input, field_type, enum_values))
|
||||
};
|
||||
|
||||
if let Some(v) = value {
|
||||
if !v.is_null() {
|
||||
data.insert(name.clone(), v);
|
||||
}
|
||||
}
|
||||
|
||||
if is_required && !data.contains_key(name) {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Required field '{}' is missing", name)).red()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
fn read_line() -> io::Result<Option<String>> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
let mut line = String::new();
|
||||
io::stdin().lock().read_line(&mut line)?;
|
||||
return Ok(Some(line.trim().to_string()));
|
||||
}
|
||||
|
||||
let mut line = String::new();
|
||||
match io::stdin().lock().read_line(&mut line) {
|
||||
Ok(0) => Ok(None), // EOF
|
||||
Ok(_) => Ok(Some(line.trim().to_string())),
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_default(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_value(input: &str, field_type: &str, enum_values: Option<&Vec<Value>>) -> Value {
|
||||
if let Some(options) = enum_values {
|
||||
let valid: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect();
|
||||
if valid.contains(&input) {
|
||||
return Value::String(input.to_string());
|
||||
}
|
||||
if let Ok(idx) = input.parse::<usize>() {
|
||||
if idx > 0 && idx <= valid.len() {
|
||||
return Value::String(valid[idx - 1].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match field_type {
|
||||
"boolean" => {
|
||||
let lower = input.to_lowercase();
|
||||
Value::Bool(matches!(lower.as_str(), "true" | "yes" | "y" | "1"))
|
||||
}
|
||||
"integer" => input
|
||||
.parse::<i64>()
|
||||
.map(|n| Value::Number(n.into()))
|
||||
.unwrap_or(Value::Null),
|
||||
"number" => input
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.and_then(serde_json::Number::from_f64)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null),
|
||||
_ => Value::String(input.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,20 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin
|
||||
tool_name
|
||||
));
|
||||
}
|
||||
ActionRequiredData::Elicitation { message, .. } => {
|
||||
md.push_str(&format!(
|
||||
"**Action Required** (elicitation): {}\n\n",
|
||||
message
|
||||
));
|
||||
}
|
||||
ActionRequiredData::ElicitationResponse { id, user_data } => {
|
||||
md.push_str(&format!(
|
||||
"**Action Required** (elicitation_response): {}\n```json\n{}\n```\n\n",
|
||||
id,
|
||||
serde_json::to_string_pretty(user_data)
|
||||
.unwrap_or_else(|_| "{}".to_string())
|
||||
));
|
||||
}
|
||||
},
|
||||
MessageContent::Text(text) => {
|
||||
md.push_str(&text.text);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod builder;
|
||||
mod completion;
|
||||
mod elicitation;
|
||||
mod export;
|
||||
mod input;
|
||||
mod output;
|
||||
@@ -865,6 +866,18 @@ impl CliSession {
|
||||
}
|
||||
});
|
||||
|
||||
let elicitation_request = message.content.iter().find_map(|content| {
|
||||
if let MessageContent::ActionRequired(action) = content {
|
||||
if let ActionRequiredData::Elicitation { id, message, requested_schema } = &action.data {
|
||||
Some((id.clone(), message.clone(), requested_schema.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((id, _tool_name, _arguments, security_prompt)) = tool_call_confirmation {
|
||||
output::hide_thinking();
|
||||
|
||||
@@ -924,6 +937,48 @@ impl CliSession {
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
else if let Some((elicitation_id, elicitation_message, schema)) = elicitation_request {
|
||||
output::hide_thinking();
|
||||
let _ = progress_bars.hide();
|
||||
|
||||
match elicitation::collect_elicitation_input(&elicitation_message, &schema) {
|
||||
Ok(Some(user_data)) => {
|
||||
let user_data_value = serde_json::to_value(user_data)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
|
||||
let response_message = Message::user()
|
||||
.with_content(MessageContent::action_required_elicitation_response(
|
||||
elicitation_id.clone(),
|
||||
user_data_value,
|
||||
))
|
||||
.with_visibility(false, true);
|
||||
|
||||
self.messages.push(response_message.clone());
|
||||
// Elicitation responses return an empty stream - the response
|
||||
// unblocks the waiting tool call via ActionRequiredManager
|
||||
let _ = self
|
||||
.agent
|
||||
.reply(
|
||||
response_message,
|
||||
session_config.clone(),
|
||||
Some(cancel_token.clone()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(None) => {
|
||||
output::render_text("Information request cancelled.", Some(Color::Yellow), true);
|
||||
cancel_token_clone.cancel();
|
||||
drop(stream);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
output::render_error(&format!("Failed to collect input: {}", e));
|
||||
cancel_token_clone.cancel();
|
||||
drop(stream);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for content in &message.content {
|
||||
if let MessageContent::ToolRequest(tool_request) = content {
|
||||
|
||||
@@ -172,6 +172,12 @@ pub fn render_message(message: &Message, debug: bool) {
|
||||
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
|
||||
println!("action_required(tool_confirmation): {}", tool_name)
|
||||
}
|
||||
ActionRequiredData::Elicitation { message, .. } => {
|
||||
println!("action_required(elicitation): {}", message)
|
||||
}
|
||||
ActionRequiredData::ElicitationResponse { id, .. } => {
|
||||
println!("action_required(elicitation_response): {}", id)
|
||||
}
|
||||
},
|
||||
MessageContent::Text(text) => print_markdown(&text.text, theme),
|
||||
MessageContent::ToolRequest(req) => render_tool_request(req, theme, debug),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, Mutex, RwLock};
|
||||
use tokio::time::timeout;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
|
||||
struct PendingRequest {
|
||||
response_tx: Option<tokio::sync::oneshot::Sender<Value>>,
|
||||
}
|
||||
|
||||
pub struct ActionRequiredManager {
|
||||
pending: Arc<RwLock<HashMap<String, Arc<Mutex<PendingRequest>>>>>,
|
||||
request_tx: mpsc::UnboundedSender<Message>,
|
||||
pub request_rx: Mutex<mpsc::UnboundedReceiver<Message>>,
|
||||
}
|
||||
|
||||
impl ActionRequiredManager {
|
||||
fn new() -> Self {
|
||||
let (request_tx, request_rx) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
pending: Arc::new(RwLock::new(HashMap::new())),
|
||||
request_tx,
|
||||
request_rx: Mutex::new(request_rx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn global() -> &'static Self {
|
||||
static INSTANCE: once_cell::sync::Lazy<ActionRequiredManager> =
|
||||
once_cell::sync::Lazy::new(ActionRequiredManager::new);
|
||||
&INSTANCE
|
||||
}
|
||||
|
||||
pub async fn request_and_wait(
|
||||
&self,
|
||||
message: String,
|
||||
schema: Value,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<Value> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let pending_request = PendingRequest {
|
||||
response_tx: Some(tx),
|
||||
};
|
||||
|
||||
self.pending
|
||||
.write()
|
||||
.await
|
||||
.insert(id.clone(), Arc::new(Mutex::new(pending_request)));
|
||||
|
||||
let action_required_message = Message::assistant().with_content(
|
||||
MessageContent::action_required_elicitation(id.clone(), message, schema),
|
||||
);
|
||||
|
||||
if let Err(e) = self.request_tx.send(action_required_message) {
|
||||
warn!("Failed to send action required message: {}", e);
|
||||
}
|
||||
|
||||
let result = match timeout(timeout_duration, rx).await {
|
||||
Ok(Ok(user_data)) => Ok(user_data),
|
||||
Ok(Err(_)) => {
|
||||
warn!("Response channel closed for request: {}", id);
|
||||
Err(anyhow::anyhow!("Response channel closed"))
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("Timeout waiting for response: {}", id);
|
||||
Err(anyhow::anyhow!("Timeout waiting for user response"))
|
||||
}
|
||||
};
|
||||
|
||||
self.pending.write().await.remove(&id);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn submit_response(&self, request_id: String, user_data: Value) -> Result<()> {
|
||||
let pending_arc = {
|
||||
let pending = self.pending.read().await;
|
||||
pending
|
||||
.get(&request_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
|
||||
};
|
||||
|
||||
let mut pending = pending_arc.lock().await;
|
||||
if let Some(tx) = pending.response_tx.take() {
|
||||
if tx.send(user_data).is_err() {
|
||||
warn!("Failed to send response through oneshot channel");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ use futures::stream::BoxStream;
|
||||
use futures::{stream, FutureExt, Stream, StreamExt, TryStreamExt};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::final_output_tool::FinalOutputTool;
|
||||
use super::platform_tools;
|
||||
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
|
||||
use crate::action_required_manager::ActionRequiredManager;
|
||||
use crate::agents::extension::{ExtensionConfig, ExtensionError, ExtensionResult, ToolInfo};
|
||||
use crate::agents::extension_manager::{get_parameter_names, ExtensionManager};
|
||||
use crate::agents::extension_manager_extension::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
|
||||
@@ -25,6 +29,7 @@ use crate::agents::subagent_execution_tool::subagent_execute_task_tool::{
|
||||
self, SUBAGENT_EXECUTE_TASK_TOOL_NAME,
|
||||
};
|
||||
use crate::agents::subagent_execution_tool::tasks_manager::TasksManager;
|
||||
use crate::agents::subagent_task_config::TaskConfig;
|
||||
use crate::agents::tool_route_manager::ToolRouteManager;
|
||||
use crate::agents::tool_router_index_manager::ToolRouterIndexManager;
|
||||
use crate::agents::types::SessionConfig;
|
||||
@@ -33,6 +38,9 @@ use crate::config::{get_enabled_extensions, Config, GooseMode};
|
||||
use crate::context_mgmt::{
|
||||
check_if_compaction_needed, compact_messages, DEFAULT_COMPACTION_THRESHOLD,
|
||||
};
|
||||
use crate::conversation::message::{
|
||||
ActionRequiredData, Message, MessageContent, SystemNotificationType, ToolRequest,
|
||||
};
|
||||
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
|
||||
use crate::mcp_utils::ToolResult;
|
||||
use crate::permission::permission_inspector::PermissionInspector;
|
||||
@@ -41,7 +49,10 @@ use crate::permission::PermissionConfirmation;
|
||||
use crate::providers::base::Provider;
|
||||
use crate::providers::errors::ProviderError;
|
||||
use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe};
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use crate::security::security_inspector::SecurityInspector;
|
||||
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
|
||||
use crate::session::{Session, SessionManager};
|
||||
use crate::tool_inspection::ToolInspectionManager;
|
||||
use crate::tool_monitor::RepetitionInspector;
|
||||
use crate::utils::is_token_cancelled;
|
||||
@@ -55,15 +66,6 @@ use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use super::final_output_tool::FinalOutputTool;
|
||||
use super::platform_tools;
|
||||
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
|
||||
use crate::agents::subagent_task_config::TaskConfig;
|
||||
use crate::conversation::message::{Message, SystemNotificationType, ToolRequest};
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
|
||||
use crate::session::{Session, SessionManager};
|
||||
|
||||
const DEFAULT_MAX_TURNS: u32 = 1000;
|
||||
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
|
||||
pub const MANUAL_COMPACT_TRIGGERS: &[&str] =
|
||||
@@ -241,6 +243,17 @@ impl Agent {
|
||||
| RetryResult::SuccessChecksPassed => Ok(false),
|
||||
}
|
||||
}
|
||||
async fn drain_elicitation_messages(session_id: &str) -> Vec<Message> {
|
||||
let mut messages = Vec::new();
|
||||
let mut elicitation_rx = ActionRequiredManager::global().request_rx.lock().await;
|
||||
while let Ok(elicitation_message) = elicitation_rx.try_recv() {
|
||||
if let Err(e) = SessionManager::add_message(session_id, &elicitation_message).await {
|
||||
warn!("Failed to save elicitation message to session: {}", e);
|
||||
}
|
||||
messages.push(elicitation_message);
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
async fn prepare_reply_context(
|
||||
&self,
|
||||
@@ -784,6 +797,29 @@ impl Agent {
|
||||
session_config: SessionConfig,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
) -> Result<BoxStream<'_, Result<AgentEvent>>> {
|
||||
for content in &user_message.content {
|
||||
if let MessageContent::ActionRequired(action_required) = content {
|
||||
if let ActionRequiredData::ElicitationResponse { id, user_data } =
|
||||
&action_required.data
|
||||
{
|
||||
if let Err(e) = 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),
|
||||
))
|
||||
})));
|
||||
}
|
||||
SessionManager::add_message(&session_config.id, &user_message).await?;
|
||||
return Ok(Box::pin(futures::stream::empty()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let message_text = user_message.as_concat_text();
|
||||
let is_manual_compact = MANUAL_COMPACT_TRIGGERS.contains(&message_text.trim());
|
||||
|
||||
@@ -1133,6 +1169,11 @@ impl Agent {
|
||||
if is_token_cancelled(&cancel_token) {
|
||||
break;
|
||||
}
|
||||
|
||||
for msg in Self::drain_elicitation_messages(&session_config.id).await {
|
||||
yield AgentEvent::Message(msg);
|
||||
}
|
||||
|
||||
match item {
|
||||
ToolStreamItem::Result(output) => {
|
||||
if enable_extension_request_ids.contains(&request_id)
|
||||
@@ -1151,6 +1192,11 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// check for remaining elicitation messages after all tools complete
|
||||
for msg in Self::drain_elicitation_messages(&session_config.id).await {
|
||||
yield AgentEvent::Message(msg);
|
||||
}
|
||||
|
||||
if all_install_successful && !enable_extension_request_ids.is_empty() {
|
||||
if let Err(e) = self.save_extension_state(&session_config).await {
|
||||
warn!("Failed to save extension state after runtime changes: {}", e);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use crate::action_required_manager::ActionRequiredManager;
|
||||
use crate::agents::types::SharedProvider;
|
||||
use crate::session_context::SESSION_ID_HEADER;
|
||||
use rmcp::model::{Content, ErrorCode, JsonObject};
|
||||
use rmcp::model::{
|
||||
Content, CreateElicitationRequestParam, CreateElicitationResult, ElicitationAction, ErrorCode,
|
||||
JsonObject,
|
||||
};
|
||||
/// MCP client implementation for Goose
|
||||
use rmcp::{
|
||||
model::{
|
||||
@@ -218,10 +222,46 @@ impl ClientHandler for GooseClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_elicitation(
|
||||
&self,
|
||||
request: CreateElicitationRequestParam,
|
||||
_context: RequestContext<RoleClient>,
|
||||
) -> Result<CreateElicitationResult, ErrorData> {
|
||||
let schema_value = serde_json::to_value(&request.requested_schema).map_err(|e| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!("Failed to serialize elicitation schema: {}", e),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
|
||||
ActionRequiredManager::global()
|
||||
.request_and_wait(
|
||||
request.message.clone(),
|
||||
schema_value,
|
||||
Duration::from_secs(300),
|
||||
)
|
||||
.await
|
||||
.map(|user_data| CreateElicitationResult {
|
||||
action: ElicitationAction::Accept,
|
||||
content: Some(user_data),
|
||||
})
|
||||
.map_err(|e| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!("Elicitation request timed out or failed: {}", e),
|
||||
None,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ClientInfo {
|
||||
ClientInfo {
|
||||
protocol_version: ProtocolVersion::V_2025_03_26,
|
||||
capabilities: ClientCapabilities::builder().enable_sampling().build(),
|
||||
capabilities: ClientCapabilities::builder()
|
||||
.enable_sampling()
|
||||
.enable_elicitation()
|
||||
.build(),
|
||||
client_info: Implementation {
|
||||
name: "goose".to_string(),
|
||||
version: std::env::var("GOOSE_MCP_CLIENT_VERSION")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::conversation::message::MessageMetadata;
|
||||
use crate::conversation::message::{ActionRequiredData, MessageMetadata};
|
||||
use crate::conversation::message::{Message, MessageContent};
|
||||
use crate::conversation::{merge_consecutive_messages, Conversation};
|
||||
use crate::prompt_template::render_global_file;
|
||||
@@ -376,12 +376,15 @@ fn format_message_for_compacting(msg: &Message) -> String {
|
||||
format!("tool_confirmation_request: {}", req.tool_name)
|
||||
}
|
||||
MessageContent::ActionRequired(action) => match &action.data {
|
||||
crate::conversation::message::ActionRequiredData::ToolConfirmation {
|
||||
tool_name,
|
||||
..
|
||||
} => {
|
||||
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
|
||||
format!("action_required(tool_confirmation): {}", tool_name)
|
||||
}
|
||||
ActionRequiredData::Elicitation { message, .. } => {
|
||||
format!("action_required(elicitation): {}", message)
|
||||
}
|
||||
ActionRequiredData::ElicitationResponse { id, .. } => {
|
||||
format!("action_required(elicitation_response): {}", id)
|
||||
}
|
||||
},
|
||||
MessageContent::FrontendToolRequest(req) => {
|
||||
if let Ok(call) = &req.tool_call {
|
||||
|
||||
@@ -111,6 +111,15 @@ pub enum ActionRequiredData {
|
||||
arguments: JsonObject,
|
||||
prompt: Option<String>,
|
||||
},
|
||||
Elicitation {
|
||||
id: String,
|
||||
message: String,
|
||||
requested_schema: serde_json::Value,
|
||||
},
|
||||
ElicitationResponse {
|
||||
id: String,
|
||||
user_data: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
@@ -192,6 +201,12 @@ impl fmt::Display for MessageContent {
|
||||
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
|
||||
write!(f, "[ActionRequired: ToolConfirmation for {}]", tool_name)
|
||||
}
|
||||
ActionRequiredData::Elicitation { message, .. } => {
|
||||
write!(f, "[ActionRequired: Elicitation - {}]", message)
|
||||
}
|
||||
ActionRequiredData::ElicitationResponse { id, .. } => {
|
||||
write!(f, "[ActionRequired: ElicitationResponse for {}]", id)
|
||||
}
|
||||
},
|
||||
MessageContent::FrontendToolRequest(r) => match &r.tool_call {
|
||||
Ok(tool_call) => write!(f, "[FrontendToolRequest: {}]", tool_call.name),
|
||||
@@ -274,6 +289,32 @@ impl MessageContent {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn action_required_elicitation<S: Into<String>>(
|
||||
id: S,
|
||||
message: String,
|
||||
requested_schema: serde_json::Value,
|
||||
) -> Self {
|
||||
MessageContent::ActionRequired(ActionRequired {
|
||||
data: ActionRequiredData::Elicitation {
|
||||
id: id.into(),
|
||||
message,
|
||||
requested_schema,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn action_required_elicitation_response<S: Into<String>>(
|
||||
id: S,
|
||||
user_data: serde_json::Value,
|
||||
) -> Self {
|
||||
MessageContent::ActionRequired(ActionRequired {
|
||||
data: ActionRequiredData::ElicitationResponse {
|
||||
id: id.into(),
|
||||
user_data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thinking<S1: Into<String>, S2: Into<String>>(thinking: S1, signature: S2) -> Self {
|
||||
MessageContent::Thinking(ThinkingContent {
|
||||
thinking: thinking.into(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod action_required_manager;
|
||||
pub mod agents;
|
||||
pub mod config;
|
||||
pub mod context_mgmt;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"sampling":{}},"clientInfo":{"name":"goose","version":"0.0.0"}}}
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation": {}, "sampling":{}},"clientInfo":{"name":"goose","version":"0.0.0"}}}
|
||||
STDERR: [2m2025-10-30T14:41:09.117156Z[0m [32m INFO[0m [1;32mgoose_mcp::mcp_server_runner[0m[32m: [32mStarting MCP server[0m
|
||||
STDERR: [2;3mat[0m crates/goose-mcp/src/mcp_server_runner.rs:18
|
||||
STDERR:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"sampling":{}},"clientInfo":{"name":"goose","version":"0.0.0"}}}
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation": {}, "sampling":{}},"clientInfo":{"name":"goose","version":"0.0.0"}}}
|
||||
STDERR: GitHub MCP Server running on stdio
|
||||
STDOUT: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"logging":{},"prompts":{},"resources":{"subscribe":true,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"github-mcp-server","version":"version"}}}
|
||||
STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"sampling":{}},"clientInfo":{"name":"goose","version":"0.0.0"}}}
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation": {}, "sampling":{}},"clientInfo":{"name":"goose","version":"0.0.0"}}}
|
||||
STDERR: Starting default (STDIO) server...
|
||||
STDERR: Starting logs update interval
|
||||
STDOUT: {"result":{"protocolVersion":"2025-03-26","capabilities":{"prompts":{},"resources":{"subscribe":true},"tools":{},"logging":{},"completions":{}},"serverInfo":{"name":"example-servers/everything","title":"Everything Example Server","version":"1.0.0"},"instructions":"Testing and demonstration server for MCP protocol features.\n\n## Resources\n\nResources 1-100 follow pattern: even IDs contain text, odd IDs contain binary data. Resources are paginated at 10 items per page with cursor-based navigation.\n\n## Key dependencies\n\nProgress notifications require `_meta.progressToken` in tool calls. Resource subscriptions generate updates every 10 seconds.\n\n## Performance characteristics\n\nServer generates automatic log messages every 20 seconds (filtered by current log level), stderr notifications every 30 seconds, and resource update notifications every 10 seconds for active subscriptions. Log level changes affect message filtering in real-time.\n\n## Multi-modal testing\n\n`complex_prompt` includes both text arguments and image content for testing client multi-modal handling. `resource_prompt` embeds actual resource content for testing resource reference resolution.\n\nArgument completion is available for prompt parameters and resource IDs. Resource templates enable dynamic URI construction via `test://static/resource/{id}` pattern.\n\n## Easter egg\n\nIf asked about server instructions, respond with \"🎉 Server instructions are working! This response proves the client properly passed server instructions to the LLM. This demonstrates MCP's instructions feature in action.\"\n"},"jsonrpc":"2.0","id":0}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"sampling":{}},"clientInfo":{"name":"goose","version":"0.0.0"}}}
|
||||
STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation": {}, "sampling":{}},"clientInfo":{"name":"goose","version":"0.0.0"}}}
|
||||
STDOUT: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"experimental":{},"prompts":{"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"mcp-fetch","version":"1.19.0"}}}
|
||||
STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"}
|
||||
STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"progressToken":0},"name":"fetch","arguments":{"url":"https://example.com"}}}
|
||||
|
||||
@@ -2428,6 +2428,50 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"message",
|
||||
"requested_schema",
|
||||
"actionType"
|
||||
],
|
||||
"properties": {
|
||||
"actionType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"elicitation"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requested_schema": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"user_data",
|
||||
"actionType"
|
||||
],
|
||||
"properties": {
|
||||
"actionType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"elicitationResponse"
|
||||
]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_data": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"discriminator": {
|
||||
|
||||
@@ -14,6 +14,15 @@ export type ActionRequiredData = {
|
||||
id: string;
|
||||
prompt?: string | null;
|
||||
toolName: string;
|
||||
} | {
|
||||
actionType: 'elicitation';
|
||||
id: string;
|
||||
message: string;
|
||||
requested_schema: unknown;
|
||||
} | {
|
||||
actionType: 'elicitationResponse';
|
||||
id: string;
|
||||
user_data: unknown;
|
||||
};
|
||||
|
||||
export type AddExtensionRequest = {
|
||||
|
||||
@@ -96,6 +96,7 @@ function BaseChatContent({
|
||||
messages,
|
||||
chatState,
|
||||
handleSubmit,
|
||||
submitElicitationResponse,
|
||||
stopStreaming,
|
||||
sessionLoadError,
|
||||
setRecipeUserParams,
|
||||
@@ -274,6 +275,7 @@ function BaseChatContent({
|
||||
isStreamingMessage={chatState !== ChatState.Idle}
|
||||
onRenderingComplete={handleRenderingComplete}
|
||||
onMessageUpdate={onMessageUpdate}
|
||||
submitElicitationResponse={submitElicitationResponse}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { ActionRequired } from '../api';
|
||||
import JsonSchemaForm from './ui/JsonSchemaForm';
|
||||
import type { JsonSchema } from './ui/JsonSchemaForm';
|
||||
|
||||
interface ElicitationRequestProps {
|
||||
isCancelledMessage: boolean;
|
||||
isClicked: boolean;
|
||||
actionRequiredContent: ActionRequired & { type: 'actionRequired' };
|
||||
onSubmit: (elicitationId: string, userData: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export default function ElicitationRequest({
|
||||
isCancelledMessage,
|
||||
isClicked,
|
||||
actionRequiredContent,
|
||||
onSubmit,
|
||||
}: ElicitationRequestProps) {
|
||||
const [submitted, setSubmitted] = useState(isClicked);
|
||||
|
||||
if (actionRequiredContent.data.actionType !== 'elicitation') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id: elicitationId, message, requested_schema } = actionRequiredContent.data;
|
||||
|
||||
const handleSubmit = (formData: Record<string, unknown>) => {
|
||||
setSubmitted(true);
|
||||
onSubmit(elicitationId, formData);
|
||||
};
|
||||
|
||||
if (isCancelledMessage) {
|
||||
return (
|
||||
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
|
||||
Information request was cancelled.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="goose-message-content bg-background-muted rounded-2xl px-4 py-2 text-textStandard">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Information submitted</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<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>
|
||||
<div className="goose-message-content bg-background-default border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 py-3">
|
||||
<JsonSchemaForm
|
||||
schema={requested_schema as JsonSchema}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
getToolRequests,
|
||||
getToolResponses,
|
||||
getToolConfirmationContent,
|
||||
getElicitationContent,
|
||||
NotificationEvent,
|
||||
} from '../types/message';
|
||||
import { Message, confirmToolAction } from '../api';
|
||||
import ToolCallConfirmation from './ToolCallConfirmation';
|
||||
import ElicitationRequest from './ElicitationRequest';
|
||||
import MessageCopyLink from './MessageCopyLink';
|
||||
import { cn } from '../utils';
|
||||
import { identifyConsecutiveToolCalls, shouldHideTimestamp } from '../utils/toolCallChaining';
|
||||
@@ -28,6 +30,10 @@ interface GooseMessageProps {
|
||||
toolCallNotifications: Map<string, NotificationEvent[]>;
|
||||
append: (value: string) => void;
|
||||
isStreaming?: boolean; // Whether this message is currently being streamed
|
||||
submitElicitationResponse?: (
|
||||
elicitationId: string,
|
||||
userData: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function GooseMessage({
|
||||
@@ -38,6 +44,7 @@ export default function GooseMessage({
|
||||
toolCallNotifications,
|
||||
append,
|
||||
isStreaming = false,
|
||||
submitElicitationResponse,
|
||||
}: GooseMessageProps) {
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const handledToolConfirmations = useRef<Set<string>>(new Set());
|
||||
@@ -69,12 +76,14 @@ export default function GooseMessage({
|
||||
const toolRequests = getToolRequests(message);
|
||||
const messageIndex = messages.findIndex((msg) => msg.id === message.id);
|
||||
const toolConfirmationContent = getToolConfirmationContent(message);
|
||||
const elicitationContent = getElicitationContent(message);
|
||||
const toolCallChains = useMemo(() => identifyConsecutiveToolCalls(messages), [messages]);
|
||||
const hideTimestamp = useMemo(
|
||||
() => shouldHideTimestamp(messageIndex, toolCallChains),
|
||||
[messageIndex, toolCallChains]
|
||||
);
|
||||
const hasToolConfirmation = toolConfirmationContent !== undefined;
|
||||
const hasElicitation = elicitationContent !== undefined;
|
||||
|
||||
const toolResponsesMap = useMemo(() => {
|
||||
const responseMap = new Map();
|
||||
@@ -219,6 +228,15 @@ export default function GooseMessage({
|
||||
actionRequiredContent={toolConfirmationContent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasElicitation && submitElicitationResponse && (
|
||||
<ElicitationRequest
|
||||
isCancelledMessage={messageIndex == messageHistoryIndex - 1}
|
||||
isClicked={messageIndex < messageHistoryIndex}
|
||||
actionRequiredContent={elicitationContent}
|
||||
onSubmit={submitElicitationResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,10 @@ interface ProgressiveMessageListProps {
|
||||
isStreamingMessage?: boolean; // Whether messages are currently being streamed
|
||||
onMessageUpdate?: (messageId: string, newContent: string) => void;
|
||||
onRenderingComplete?: () => void; // Callback when all messages are rendered
|
||||
submitElicitationResponse?: (
|
||||
elicitationId: string,
|
||||
userData: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ProgressiveMessageList({
|
||||
@@ -53,6 +57,7 @@ export default function ProgressiveMessageList({
|
||||
isStreamingMessage = false, // Whether messages are currently being streamed
|
||||
onMessageUpdate,
|
||||
onRenderingComplete,
|
||||
submitElicitationResponse,
|
||||
}: ProgressiveMessageListProps) {
|
||||
const [renderedCount, setRenderedCount] = useState(() => {
|
||||
// Initialize with either all messages (if small) or first batch (if large)
|
||||
@@ -223,6 +228,7 @@ export default function ProgressiveMessageList({
|
||||
index === messagesToRender.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
}
|
||||
submitElicitationResponse={submitElicitationResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -240,6 +246,7 @@ export default function ProgressiveMessageList({
|
||||
isStreamingMessage,
|
||||
onMessageUpdate,
|
||||
toolCallChains,
|
||||
submitElicitationResponse,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,8 @@ const toolConfirmationState = new Map<
|
||||
}
|
||||
>();
|
||||
|
||||
type ToolConfirmationData = Extract<ActionRequired['data'], { actionType: 'toolConfirmation' }>;
|
||||
|
||||
interface ToolConfirmationProps {
|
||||
sessionId: string;
|
||||
isCancelledMessage: boolean;
|
||||
@@ -33,7 +35,8 @@ export default function ToolConfirmation({
|
||||
isClicked,
|
||||
actionRequiredContent,
|
||||
}: ToolConfirmationProps) {
|
||||
const { id: toolConfirmationId, toolName, prompt } = actionRequiredContent.data;
|
||||
const data = actionRequiredContent.data as ToolConfirmationData;
|
||||
const { id: toolConfirmationId, toolName, prompt } = data;
|
||||
|
||||
// Check if we have a stored state for this tool confirmation
|
||||
const storedState = toolConfirmationState.get(toolConfirmationId);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Input } from './input';
|
||||
import { Button } from './button';
|
||||
|
||||
interface JsonSchemaProperty {
|
||||
type?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export interface JsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface JsonSchemaFormProps {
|
||||
schema: JsonSchema;
|
||||
onSubmit: (data: Record<string, unknown>) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel?: string;
|
||||
cancelLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function JsonSchemaForm({
|
||||
schema,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel = 'Submit',
|
||||
cancelLabel = 'Cancel',
|
||||
disabled = false,
|
||||
}: JsonSchemaFormProps) {
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(() => {
|
||||
const initial: Record<string, unknown> = {};
|
||||
if (schema.properties) {
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
if (prop.default !== undefined) {
|
||||
initial[key] = prop.default;
|
||||
} else if (prop.type === 'boolean') {
|
||||
initial[key] = false;
|
||||
} else if (prop.type === 'number' || prop.type === 'integer') {
|
||||
initial[key] = prop.minimum ?? 0;
|
||||
} else {
|
||||
initial[key] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateField = useCallback(
|
||||
(key: string, value: unknown): string | null => {
|
||||
const prop = schema.properties?.[key];
|
||||
if (!prop) return null;
|
||||
|
||||
const isRequired = schema.required?.includes(key);
|
||||
|
||||
if (isRequired && (value === '' || value === null || value === undefined)) {
|
||||
return 'This field is required';
|
||||
}
|
||||
|
||||
if (prop.type === 'string' && typeof value === 'string') {
|
||||
if (!isRequired && value === '') return null;
|
||||
|
||||
if (prop.minLength !== undefined && value.length < prop.minLength) {
|
||||
return `Minimum length is ${prop.minLength}`;
|
||||
}
|
||||
if (prop.maxLength !== undefined && value.length > prop.maxLength) {
|
||||
return `Maximum length is ${prop.maxLength}`;
|
||||
}
|
||||
}
|
||||
|
||||
if ((prop.type === 'number' || prop.type === 'integer') && typeof value === 'number') {
|
||||
if (prop.minimum !== undefined && value < prop.minimum) {
|
||||
return `Minimum value is ${prop.minimum}`;
|
||||
}
|
||||
if (prop.maximum !== undefined && value > prop.maximum) {
|
||||
return `Maximum value is ${prop.maximum}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[schema]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const error = validateField(key, value);
|
||||
setErrors((prev) => {
|
||||
if (error) {
|
||||
return { ...prev, [key]: error };
|
||||
}
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[key];
|
||||
return newErrors;
|
||||
});
|
||||
},
|
||||
[validateField]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (schema.properties) {
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
const error = validateField(key, formData[key]);
|
||||
if (error) {
|
||||
newErrors[key] = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(formData);
|
||||
},
|
||||
[formData, onSubmit, schema.properties, validateField]
|
||||
);
|
||||
|
||||
const renderField = (key: string, prop: JsonSchemaProperty) => {
|
||||
const value = formData[key];
|
||||
const error = errors[key];
|
||||
const isRequired = schema.required?.includes(key);
|
||||
|
||||
if (prop.enum) {
|
||||
return (
|
||||
<select
|
||||
id={key}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex h-9 w-full rounded-md border focus:border-border-strong hover:border-border-strong bg-background-default px-3 py-1 text-base transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
||||
>
|
||||
{!isRequired && <option value="">Select...</option>}
|
||||
{prop.enum.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (prop.type === 'boolean') {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => handleChange(key, e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-textStandard">{prop.description || key}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (prop.type === 'number' || prop.type === 'integer') {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
id={key}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => {
|
||||
const numValue =
|
||||
prop.type === 'integer' ? parseInt(e.target.value, 10) : parseFloat(e.target.value);
|
||||
handleChange(key, isNaN(numValue) ? '' : numValue);
|
||||
}}
|
||||
min={prop.minimum}
|
||||
max={prop.maximum}
|
||||
step={prop.type === 'integer' ? 1 : 'any'}
|
||||
disabled={disabled}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
id={key}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
minLength={prop.minLength}
|
||||
maxLength={prop.maxLength}
|
||||
disabled={disabled}
|
||||
className={error ? 'border-red-500' : ''}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!schema.properties || Object.keys(schema.properties).length === 0) {
|
||||
return <div className="text-textSubtle text-sm">No fields to display</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{Object.entries(schema.properties).map(([key, prop]) => {
|
||||
const isRequired = schema.required?.includes(key);
|
||||
const error = errors[key];
|
||||
|
||||
if (prop.type === 'boolean') {
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
{renderField(key, prop)}
|
||||
{error && <span className="text-red-500 text-xs">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<label htmlFor={key} className="text-sm font-medium text-textStandard">
|
||||
{key}
|
||||
{isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{prop.description && prop.type !== 'boolean' && (
|
||||
<span className="text-xs text-textSubtle">{prop.description}</span>
|
||||
)}
|
||||
{renderField(key, prop)}
|
||||
{error && <span className="text-red-500 text-xs">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button type="submit" disabled={disabled}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={disabled}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
import {
|
||||
createUserMessage,
|
||||
createElicitationResponseMessage,
|
||||
getCompactingMessage,
|
||||
getThinkingMessage,
|
||||
NotificationEvent,
|
||||
@@ -33,6 +34,10 @@ interface UseChatStreamReturn {
|
||||
messages: Message[];
|
||||
chatState: ChatState;
|
||||
handleSubmit: (userMessage: string) => Promise<void>;
|
||||
submitElicitationResponse: (
|
||||
elicitationId: string,
|
||||
userData: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
setRecipeUserParams: (values: Record<string, string>) => Promise<void>;
|
||||
stopStreaming: () => void;
|
||||
sessionLoadError?: string;
|
||||
@@ -89,7 +94,12 @@ async function streamFromResponse(
|
||||
(content) => content.type === 'toolConfirmationRequest'
|
||||
);
|
||||
|
||||
if (hasToolConfirmation) {
|
||||
const hasElicitation = msg.content.some(
|
||||
(content) =>
|
||||
content.type === 'actionRequired' && content.data.actionType === 'elicitation'
|
||||
);
|
||||
|
||||
if (hasToolConfirmation || hasElicitation) {
|
||||
updateChatState(ChatState.WaitingForUserInput);
|
||||
} else if (getCompactingMessage(msg)) {
|
||||
updateChatState(ChatState.Compacting);
|
||||
@@ -312,6 +322,50 @@ export function useChatStream({
|
||||
[sessionId, session, chatState, updateMessages, updateNotifications, onFinish]
|
||||
);
|
||||
|
||||
const submitElicitationResponse = useCallback(
|
||||
async (elicitationId: string, userData: Record<string, unknown>) => {
|
||||
if (!session || chatState === ChatState.LoadingConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const responseMessage = createElicitationResponseMessage(elicitationId, userData);
|
||||
const currentMessages = [...messagesRef.current, responseMessage];
|
||||
|
||||
updateMessages(currentMessages);
|
||||
setChatState(ChatState.Streaming);
|
||||
setNotifications([]);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const { stream } = await reply({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
messages: currentMessages,
|
||||
},
|
||||
throwOnError: true,
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
await streamFromResponse(
|
||||
stream,
|
||||
currentMessages,
|
||||
updateMessages,
|
||||
setTokenState,
|
||||
setChatState,
|
||||
updateNotifications,
|
||||
onFinish
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
// Silently handle abort
|
||||
} else {
|
||||
onFinish('Submit error: ' + errorMessage(error));
|
||||
}
|
||||
}
|
||||
},
|
||||
[sessionId, session, chatState, updateMessages, updateNotifications, onFinish]
|
||||
);
|
||||
|
||||
const setRecipeUserParams = useCallback(
|
||||
async (user_recipe_values: Record<string, string>) => {
|
||||
if (session) {
|
||||
@@ -437,6 +491,7 @@ export function useChatStream({
|
||||
session: maybe_cached_session,
|
||||
chatState,
|
||||
handleSubmit,
|
||||
submitElicitationResponse,
|
||||
stopStreaming,
|
||||
setRecipeUserParams,
|
||||
tokenState,
|
||||
|
||||
@@ -17,6 +17,28 @@ export function createUserMessage(text: string): Message {
|
||||
};
|
||||
}
|
||||
|
||||
export function createElicitationResponseMessage(
|
||||
elicitationId: string,
|
||||
userData: Record<string, unknown>
|
||||
): Message {
|
||||
return {
|
||||
id: generateMessageId(),
|
||||
role: 'user',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [
|
||||
{
|
||||
type: 'actionRequired',
|
||||
data: {
|
||||
actionType: 'elicitationResponse',
|
||||
id: elicitationId,
|
||||
user_data: userData,
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: { userVisible: false, agentVisible: true },
|
||||
};
|
||||
}
|
||||
|
||||
export function generateMessageId(): string {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
@@ -51,6 +73,15 @@ export function getToolConfirmationContent(
|
||||
);
|
||||
}
|
||||
|
||||
export function getElicitationContent(
|
||||
message: Message
|
||||
): (ActionRequired & { type: 'actionRequired' }) | undefined {
|
||||
return message.content.find(
|
||||
(content): content is ActionRequired & { type: 'actionRequired' } =>
|
||||
content.type === 'actionRequired' && content.data.actionType === 'elicitation'
|
||||
);
|
||||
}
|
||||
|
||||
export function hasCompletedToolCalls(message: Message): boolean {
|
||||
const toolRequests = getToolRequests(message);
|
||||
return toolRequests.length > 0;
|
||||
|
||||
Reference in New Issue
Block a user