feat(repl): add .prompt command with live staged tab-completion
Implements plans/mcp-resources-prompts-design.md §5.1/§5.4 (T6): - .prompt <server> <name> [key=value ...] fetches an MCP prompt and submits the result as chat input via Input::from_str + ask(), never through REPL line parsing; GetPromptResult messages are flattened into one user-role block with unconditional [user]/[assistant] labels - missing required prompt arguments are collected interactively - .list prompts renders server/name/description/args via the unified catalog (CatalogItem gains an arguments field), degrading per server - staged live tab-completion: enabled+running+prompts-capable servers (no RPC), then live prompt names, then key= argument suggestions with (required) markers; 2s timeout per RPC, all errors degrade to silent empty suggestions, ctx read guard dropped before blocking - the enabled-server alias expansion is factored into a shared helper used by both tool-scope rebuild and completion - BREAKING: the former .prompt <text> temp-role builtin is renamed to .temp-role <text> (behavior preserved); .prompt now belongs to MCP prompts, and a user macro named prompt or temp-role is shadowed
This commit is contained in:
+112
-22
@@ -5,7 +5,7 @@ use super::skill::{SKILL_SCAFFOLD, Skill};
|
||||
use super::skill_policy::SkillPolicy;
|
||||
use super::skill_registry::SkillRegistry;
|
||||
use super::todo::TodoList;
|
||||
use super::tool_scope::{McpRuntime, ToolScope};
|
||||
use super::tool_scope::{McpPromptCompletion, McpRuntime, ToolScope, format_prompt_arguments};
|
||||
use super::{
|
||||
AGENTS_DIR_NAME, Agent, AgentVariables, AppConfig, AppState, AssetCategory, CREATE_TITLE_ROLE,
|
||||
Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, MacroAllowlistLevel,
|
||||
@@ -23,8 +23,8 @@ use crate::function::{
|
||||
user_interaction::USER_FUNCTION_PREFIX,
|
||||
};
|
||||
use crate::mcp::{
|
||||
MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpAuthReason, McpAuthRequired, is_auth_required_error,
|
||||
is_mcp_meta_function, mcp_meta_function_names,
|
||||
CatalogItem, MCP_SEARCH_META_FUNCTION_NAME_PREFIX, McpAuthReason, McpAuthRequired,
|
||||
McpServersConfig, is_auth_required_error, is_mcp_meta_function, mcp_meta_function_names,
|
||||
};
|
||||
use crate::rag::Rag;
|
||||
use crate::supervisor::Supervisor;
|
||||
@@ -77,6 +77,29 @@ fn installed_bundle_names() -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expand_enabled_mcp_server_ids(
|
||||
app: &AppConfig,
|
||||
mcp_config: &McpServersConfig,
|
||||
enabled_mcp_servers: &[String],
|
||||
) -> Vec<String> {
|
||||
if enabled_mcp_servers.iter().any(|s| s.trim() == "all") {
|
||||
return mcp_config.mcp_servers.keys().cloned().collect();
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for item in enabled_mcp_servers.iter().map(|s| s.trim()) {
|
||||
if mcp_config.mcp_servers.contains_key(item) {
|
||||
ids.push(item.to_string());
|
||||
} else if let Some(mapped) = app.mapping_mcp_servers.get(item) {
|
||||
for mapped_id in mapped.split(',').map(|s| s.trim()) {
|
||||
if mcp_config.mcp_servers.contains_key(mapped_id) {
|
||||
ids.push(mapped_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
pub struct AutoContinueConfig {
|
||||
pub enabled: bool,
|
||||
pub max_continues: usize,
|
||||
@@ -139,6 +162,20 @@ pub(crate) fn asset_table(header: &[&str]) -> Table {
|
||||
table
|
||||
}
|
||||
|
||||
fn prompt_asset_rows(items: &[CatalogItem]) -> Vec<[String; 4]> {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
[
|
||||
item.server.clone(),
|
||||
item.name.clone(),
|
||||
item.description.clone(),
|
||||
format_prompt_arguments(item.arguments.as_deref().unwrap_or_default()),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn complete_skills_with_descriptions(names: Vec<String>) -> Vec<(String, Option<String>)> {
|
||||
names
|
||||
.into_iter()
|
||||
@@ -2779,7 +2816,7 @@ impl RequestContext {
|
||||
}
|
||||
"bundles" => bundles::list_installed_bundles(),
|
||||
_ => bail!(
|
||||
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers, bundles"
|
||||
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, prompts, tools, mcp-servers, bundles"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -3663,6 +3700,40 @@ impl RequestContext {
|
||||
fuzzy_filter(values, |v| v.0.as_str(), filter)
|
||||
}
|
||||
|
||||
pub fn mcp_prompt_completion(&self, args: &[&str]) -> McpPromptCompletion {
|
||||
let app = self.app.config.as_ref();
|
||||
let enabled_ids = match &self.app.mcp_config {
|
||||
Some(mcp_config) => {
|
||||
let mut servers = self
|
||||
.enabled_mcp_servers_for_current_scope(app, true)
|
||||
.unwrap_or_default();
|
||||
servers.extend(self.skill_registry.loaded_mcp_servers());
|
||||
expand_enabled_mcp_server_ids(app, mcp_config, &servers)
|
||||
}
|
||||
None => vec![],
|
||||
};
|
||||
self.tool_scope
|
||||
.mcp_runtime
|
||||
.prompt_completion(&enabled_ids, args)
|
||||
}
|
||||
|
||||
pub async fn list_prompt_assets(&self) -> Result<()> {
|
||||
let items = self.tool_scope.mcp_runtime.prompt_catalog().await;
|
||||
if items.is_empty() {
|
||||
println!("No prompts found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut table = asset_table(&["server", "name", "description", "args"]);
|
||||
for row in prompt_asset_rows(&items) {
|
||||
table.add_row(row.to_vec());
|
||||
}
|
||||
|
||||
println!("Prompts:");
|
||||
println!("{table}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rebuild_tool_scope(
|
||||
&mut self,
|
||||
app: &AppConfig,
|
||||
@@ -3706,24 +3777,7 @@ impl RequestContext {
|
||||
&& let Some(mcp_config) = &self.app.mcp_config
|
||||
{
|
||||
let server_ids: Vec<String> = match &enabled_mcp_servers {
|
||||
Some(servers) if servers.iter().any(|s| s.trim() == "all") => {
|
||||
mcp_config.mcp_servers.keys().cloned().collect()
|
||||
}
|
||||
Some(servers) => {
|
||||
let mut ids = Vec::new();
|
||||
for item in servers.iter().map(|s| s.trim()) {
|
||||
if mcp_config.mcp_servers.contains_key(item) {
|
||||
ids.push(item.to_string());
|
||||
} else if let Some(mapped) = app.mapping_mcp_servers.get(item) {
|
||||
for mapped_id in mapped.split(',').map(|s| s.trim()) {
|
||||
if mcp_config.mcp_servers.contains_key(mapped_id) {
|
||||
ids.push(mapped_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
Some(servers) => expand_enabled_mcp_server_ids(app, mcp_config, servers),
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
@@ -4632,6 +4686,7 @@ mod tests {
|
||||
use crate::utils;
|
||||
use crate::utils::get_env_name;
|
||||
use crate::vault::Vault;
|
||||
use rmcp::model::PromptArgument;
|
||||
use serde_json::json;
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
@@ -4801,6 +4856,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_asset_rows_assembles_columns() {
|
||||
let items = vec![
|
||||
CatalogItem {
|
||||
name: "summarize".to_string(),
|
||||
server: "docs".to_string(),
|
||||
description: "Summarize a document".to_string(),
|
||||
arguments: Some(vec![
|
||||
PromptArgument::new("path").with_required(true),
|
||||
PromptArgument::new("style"),
|
||||
]),
|
||||
..Default::default()
|
||||
},
|
||||
CatalogItem {
|
||||
name: "greet".to_string(),
|
||||
server: "misc".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
let rows = prompt_asset_rows(&items);
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(
|
||||
rows[0],
|
||||
[
|
||||
"docs",
|
||||
"summarize",
|
||||
"Summarize a document",
|
||||
"path (required), style"
|
||||
]
|
||||
);
|
||||
assert_eq!(rows[1], ["misc", "greet", "", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_role_returns_standalone_role() {
|
||||
let mut ctx = create_test_ctx();
|
||||
|
||||
Reference in New Issue
Block a user