Compare commits

...
5 Commits
7 changed files with 146 additions and 31 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ pub(in crate::config) const DEFAULT_USER_INTERACTION_INSTRUCTIONS: &str = indoc!
## User Interaction
You have built-in tools to interact with the user directly:
- `user__ask --question \"...\" --options [\"A\", \"B\", \"C\"]`: Present a selection prompt. Returns the chosen option.
- `user__select --question \"...\" --options [\"A\", \"B\", \"C\"]`: Present a single-select list of named options. Use this — not `user__confirm` — whenever there are 2+ named options. Returns the chosen option.
- `user__confirm --question \"...\"`: Ask a yes/no question. Returns \"yes\" or \"no\".
- `user__input --question \"...\"`: Request free-form text input from the user.
- `user__checkbox --question \"...\" --options [\"A\", \"B\", \"C\"]`: Multi-select prompt. Returns an array of selected options.
+3 -3
View File
@@ -4784,7 +4784,7 @@ mod tests {
let fns = ctx.select_functions(&role).unwrap();
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"todo__init"));
assert!(names.contains(&"user__ask"));
assert!(names.contains(&"user__select"));
}
#[test]
@@ -4848,7 +4848,7 @@ mod tests {
let fns = ctx.select_functions(&role).unwrap();
let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"user__ask"));
assert!(names.contains(&"user__select"));
assert!(!names.contains(&"skill__list"));
}
@@ -4933,7 +4933,7 @@ mod tests {
"teammate tools must survive an agent tool filter, got: {names:?}"
);
assert!(
names.contains(&"user__ask"),
names.contains(&"user__select"),
"user__ tools must survive an agent tool filter, got: {names:?}"
);
}
+58 -14
View File
@@ -150,7 +150,9 @@ pub async fn eval_tool_calls(
let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", msg.trim());
println!(
"{}",
warning_text(format!("{}: ⚠️ Tool-call loop detected! ⚠️", call.name).as_str())
muted_warning_text(
format!("{}: ⚠️ Tool-call loop detected! ⚠️", call.name).as_str()
)
);
let val = json!(dup_msg);
output.push(ToolResult::new(call, val));
@@ -1088,10 +1090,8 @@ impl ToolCall {
cmd_args.push(json_data.to_string());
let prompt = format!("Call {cmd_name} {}", cmd_args.join(" "));
if *IS_STDOUT_TERMINAL && current_depth == 0 {
println!("{}", dimmed_text(&prompt));
println!("{}", format_call_log(&cmd_name, &cmd_args, &json_data));
}
let output = match cmd_name.as_str() {
@@ -1100,7 +1100,7 @@ impl ToolCall {
.await
.unwrap_or_else(|e| {
let error_msg = format!("MCP search failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
@@ -1109,7 +1109,7 @@ impl ToolCall {
.await
.unwrap_or_else(|e| {
let error_msg = format!("MCP describe failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
@@ -1118,21 +1118,21 @@ impl ToolCall {
.await
.unwrap_or_else(|e| {
let error_msg = format!("MCP tool invocation failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
_ if cmd_name.starts_with(TODO_FUNCTION_PREFIX) => {
todo::handle_todo_tool(ctx, &cmd_name, &json_data).unwrap_or_else(|e| {
let error_msg = format!("Todo tool failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
_ if cmd_name.starts_with(MEMORY_FUNCTION_PREFIX) => {
memory::handle_memory_tool(ctx, &cmd_name, &json_data).unwrap_or_else(|e| {
let error_msg = format!("Memory tool failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
@@ -1141,7 +1141,7 @@ impl ToolCall {
.await
.unwrap_or_else(|e| {
let error_msg = format!("Skill tool failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
@@ -1150,7 +1150,7 @@ impl ToolCall {
.await
.unwrap_or_else(|e| {
let error_msg = format!("Supervisor tool failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
@@ -1159,7 +1159,7 @@ impl ToolCall {
.await
.unwrap_or_else(|e| {
let error_msg = format!("User interaction failed: {e}");
eprintln!("{}", warning_text(&format!("⚠️ {error_msg} ⚠️")));
eprintln!("{}", muted_warning_text(&format!("⚠️ {error_msg} ⚠️")));
json!({"tool_call_error": error_msg})
})
}
@@ -1419,7 +1419,10 @@ pub fn run_llm_function(
let stderr = String::from_utf8_lossy(&stderr_bytes).trim().to_string();
let stdout = String::from_utf8_lossy(&stdout_bytes).trim().to_string();
let tool_error_message = format!("Tool call '{command_name}' exited with code {exit_code}");
eprintln!("{}", warning_text(&format!("⚠️ {tool_error_message} ⚠️")));
eprintln!(
"{}",
muted_warning_text(&format!("⚠️ {tool_error_message} ⚠️"))
);
let mut error_json = json!({"tool_call_error": tool_error_message});
if !stderr.is_empty() {
error_json["stderr"] = json!(stderr);
@@ -1552,6 +1555,47 @@ impl ToolCallTracker {
}
}
fn format_call_log(cmd_name: &str, cmd_args: &[String], json_data: &serde_json::Value) -> String {
if *NO_COLOR {
return format!("Call {cmd_name} {}", cmd_args.join(" "));
}
let prefix_args = &cmd_args[..cmd_args.len().saturating_sub(1)];
let prefix = if prefix_args.is_empty() {
String::new()
} else {
format!("{} ", dimmed_text(&prefix_args.join(" ")))
};
format!(
"{}{} {}{}",
dimmed_text("Call "),
cyan_bold_text(cmd_name),
prefix,
format_json_colored_keys(json_data),
)
}
fn format_json_colored_keys(value: &serde_json::Value) -> String {
let serde_json::Value::Object(map) = value else {
return dimmed_text(&value.to_string());
};
if map.is_empty() {
return dimmed_text("{}");
}
let pairs: Vec<String> = map
.iter()
.map(|(k, v)| {
let key = magenta_text(&format!("\"{k}\""));
format!("{}{}", key, dimmed_text(&format!(": {v}")))
})
.collect();
format!(
"{}{}{}",
dimmed_text("{"),
pairs.join(&dimmed_text(", ")),
dimmed_text("}")
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1836,7 +1880,7 @@ mod tests {
fn functions_append_user_interaction_adds_declarations() {
let mut f = Functions::default();
f.append_user_interaction_functions();
assert!(f.contains("user__ask"));
assert!(f.contains("user__select"));
assert!(f.contains("user__confirm"));
assert!(f.contains("user__input"));
assert!(f.contains("user__checkbox"));
+14 -6
View File
@@ -17,8 +17,11 @@ const CUSTOM_MULTI_CHOICE_ANSWER_OPTION: &str = "Other (custom)";
pub fn user_interaction_function_declarations() -> Vec<FunctionDeclaration> {
vec![
FunctionDeclaration {
name: format!("{USER_FUNCTION_PREFIX}ask"),
description: "Ask the user to select one option from a list. Returns the selected option. Indicate the recommended choice if there is one.".to_string(),
name: format!("{USER_FUNCTION_PREFIX}select"),
description: "Present a list of named options and ask the user to pick exactly one. \
Indicate the recommended choice if there is one. \
Use this — not `confirm` — whenever there are 2+ named options to choose \
between. Returns the selected option.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([
@@ -50,7 +53,9 @@ pub fn user_interaction_function_declarations() -> Vec<FunctionDeclaration> {
},
FunctionDeclaration {
name: format!("{USER_FUNCTION_PREFIX}confirm"),
description: "Ask the user a yes/no question. Returns \"yes\" or \"no\".".to_string(),
description: "Ask a genuinely binary yes/no question with no other choices. Do NOT \
use for \"A or B?\" situations — use `select` instead. Returns \"yes\" \
or \"no\".".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
@@ -68,7 +73,8 @@ pub fn user_interaction_function_declarations() -> Vec<FunctionDeclaration> {
},
FunctionDeclaration {
name: format!("{USER_FUNCTION_PREFIX}input"),
description: "Ask the user for free-form text input. Returns the text entered.".to_string(),
description: "Collect free-form text from the user when no predefined options exist. \
Returns the text entered.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([(
@@ -86,7 +92,9 @@ pub fn user_interaction_function_declarations() -> Vec<FunctionDeclaration> {
},
FunctionDeclaration {
name: format!("{USER_FUNCTION_PREFIX}checkbox"),
description: "Ask the user to select one or more options from a list. Returns an array of selected options.".to_string(),
description: "Ask the user to pick one or more options from a list (multi-select). \
Use when multiple answers are valid simultaneously. Returns an array \
of selected options.".to_string(),
parameters: JsonSchema {
type_value: Some("object".to_string()),
properties: Some(IndexMap::from([
@@ -139,7 +147,7 @@ pub async fn handle_user_tool(
fn handle_direct(action: &str, args: &Value) -> Result<Value> {
match action {
"ask" => handle_direct_ask(args),
"select" => handle_direct_ask(args),
"confirm" => handle_direct_confirm(args),
"input" => handle_direct_input(args),
"checkbox" => handle_direct_checkbox(args),
+1 -1
View File
@@ -27,7 +27,7 @@ impl ApprovalNodeExecutor {
&json!({ "question": question, "options": node.options }),
)
.await
.context("user__ask failed")?;
.context("user__select failed")?;
if let Some(err) = response.get("error").and_then(Value::as_str) {
bail!("Approval interaction failed: {err}");
+5 -1
View File
@@ -210,7 +210,11 @@ async fn main() -> Result<()> {
{
let app = &*ctx.app.config;
if app.highlight {
set_global_render_config(prompt_theme(app.render_options()?)?)
let render_opts = app.render_options()?;
if let Some(ref theme) = render_opts.theme {
utils::init_tool_colors(theme);
}
set_global_render_config(prompt_theme(render_opts)?)
}
}
+64 -5
View File
@@ -31,9 +31,12 @@ use anyhow::{Context, Result};
use fancy_regex::Regex;
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use is_terminal::IsTerminal;
use nu_ansi_term::Color;
use std::borrow::Cow;
use std::sync::LazyLock;
use std::sync::{LazyLock, OnceLock};
use std::{cmp, env, path::PathBuf, process};
use syntect::highlighting::{Highlighter, Theme};
use syntect::parsing::Scope;
pub static CODE_BLOCK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?ms)```\w*(.*)```").unwrap());
@@ -48,6 +51,33 @@ pub static NO_COLOR: LazyLock<bool> = LazyLock::new(|| {
|| !*IS_STDOUT_TERMINAL
});
static TOOL_DIM_COLOR: OnceLock<Color> = OnceLock::new();
static TOOL_FN_COLOR: OnceLock<Color> = OnceLock::new();
static TOOL_KEY_COLOR: OnceLock<Color> = OnceLock::new();
static TOOL_WARN_COLOR: OnceLock<Color> = OnceLock::new();
pub fn init_tool_colors(theme: &Theme) {
fn resolve(theme: &Theme, scope_str: &str) -> Option<Color> {
let scope = Scope::new(scope_str).ok()?;
let style = Highlighter::new(theme).style_mod_for_stack(&[scope]);
let fg = style.foreground.or(theme.settings.foreground)?;
let mute = |ch: u8| -> u8 { ((ch as u16 + 128) / 2) as u8 };
Some(Color::Rgb(mute(fg.r), mute(fg.g), mute(fg.b)))
}
if let Some(c) = resolve(theme, "comment") {
let _ = TOOL_DIM_COLOR.set(c);
}
if let Some(c) = resolve(theme, "support.function") {
let _ = TOOL_FN_COLOR.set(c);
}
if let Some(c) = resolve(theme, "constant.numeric") {
let _ = TOOL_KEY_COLOR.set(c);
}
if let Some(c) = resolve(theme, "string") {
let _ = TOOL_WARN_COLOR.set(c);
}
}
pub fn now() -> String {
chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, false)
}
@@ -141,14 +171,22 @@ pub fn indent_text<T: ToString>(s: T, size: usize) -> String {
}
pub fn error_text(input: &str) -> String {
color_text(input, nu_ansi_term::Color::Red)
color_text(input, Color::Red)
}
pub fn warning_text(input: &str) -> String {
color_text(input, nu_ansi_term::Color::Yellow)
color_text(input, Color::Yellow)
}
pub fn color_text(input: &str, color: nu_ansi_term::Color) -> String {
pub fn muted_warning_text(input: &str) -> String {
if *NO_COLOR {
return input.to_string();
}
let color = TOOL_WARN_COLOR.get().copied().unwrap_or(Color::Fixed(136));
color.paint(input).to_string()
}
pub fn color_text(input: &str, color: Color) -> String {
if *NO_COLOR {
return input.to_string();
}
@@ -162,7 +200,28 @@ pub fn dimmed_text(input: &str) -> String {
if *NO_COLOR {
return input.to_string();
}
nu_ansi_term::Style::new().dimmed().paint(input).to_string()
let color = TOOL_DIM_COLOR.get().copied().unwrap_or(Color::Fixed(243));
color.paint(input).to_string()
}
pub fn cyan_bold_text(input: &str) -> String {
if *NO_COLOR {
return input.to_string();
}
let color = TOOL_FN_COLOR.get().copied().unwrap_or(Color::Fixed(73));
nu_ansi_term::Style::new()
.fg(color)
.bold()
.paint(input)
.to_string()
}
pub fn magenta_text(input: &str) -> String {
if *NO_COLOR {
return input.to_string();
}
let color = TOOL_KEY_COLOR.get().copied().unwrap_or(Color::Fixed(133));
color.paint(input).to_string()
}
pub fn multiline_text(input: &str) -> String {