mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
060ce8de25 |
+2
-260
@@ -7,10 +7,9 @@
|
||||
//! - `commands` - System commands and job handlers
|
||||
//! - `thread_ops` - Thread/session operations (user input, undo, approval, persistence)
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::StreamExt;
|
||||
use regex::Regex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::context_monitor::ContextMonitor;
|
||||
@@ -63,38 +62,6 @@ pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SensitiveChatCredential {
|
||||
TelegramBotToken,
|
||||
}
|
||||
|
||||
impl SensitiveChatCredential {
|
||||
fn extension_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::TelegramBotToken => "telegram",
|
||||
}
|
||||
}
|
||||
|
||||
fn redirect_message(self) -> &'static str {
|
||||
match self {
|
||||
Self::TelegramBotToken => {
|
||||
"Telegram bot tokens can't be accepted in normal chat. Use the secure Telegram setup flow instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static TELEGRAM_BOT_TOKEN_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^\d{6,}:[A-Za-z0-9_-]{20,}$").expect("TELEGRAM_BOT_TOKEN_RE")); // safety: hardcoded literal
|
||||
|
||||
fn detect_sensitive_chat_credential(content: &str) -> Option<SensitiveChatCredential> {
|
||||
let trimmed = content.trim();
|
||||
if TELEGRAM_BOT_TOKEN_RE.is_match(trimmed) {
|
||||
return Some(SensitiveChatCredential::TelegramBotToken);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resolve_routine_notification_user(metadata: &serde_json::Value) -> Option<String> {
|
||||
resolve_owner_scope_notification_user(
|
||||
@@ -224,28 +191,6 @@ pub struct Agent {
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
async fn intercept_sensitive_chat_credential(
|
||||
&self,
|
||||
message: &IncomingMessage,
|
||||
credential: SensitiveChatCredential,
|
||||
) -> String {
|
||||
let instructions = credential.redirect_message().to_string();
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
crate::channels::StatusUpdate::AuthRequired {
|
||||
extension_name: credential.extension_name().to_string(),
|
||||
instructions: Some(instructions.clone()),
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
instructions
|
||||
}
|
||||
|
||||
pub(super) fn owner_id(&self) -> &str {
|
||||
if let Some(workspace) = self.deps.workspace.as_ref() {
|
||||
debug_assert_eq!(
|
||||
@@ -1180,15 +1125,6 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
if let Submission::UserInput { ref content } = submission {
|
||||
if let Some(credential) = detect_sensitive_chat_credential(content) {
|
||||
return Ok(Some(
|
||||
self.intercept_sensitive_chat_credential(message, credential)
|
||||
.await,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
"Received message from {} on {} ({} chars)",
|
||||
message.user_id,
|
||||
@@ -1385,26 +1321,11 @@ impl Agent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
Agent, AgentDeps, SensitiveChatCredential, chat_tool_execution_metadata,
|
||||
detect_sensitive_chat_credential, resolve_routine_notification_user,
|
||||
chat_tool_execution_metadata, resolve_routine_notification_user,
|
||||
should_fallback_routine_notification, truncate_for_preview,
|
||||
};
|
||||
use crate::agent::session::Thread;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::error::ChannelError;
|
||||
use crate::testing::{StubChannel, StubLlm};
|
||||
use crate::{
|
||||
agent::cost_guard::{CostGuard, CostGuardConfig},
|
||||
channels::{ChannelManager, StatusUpdate},
|
||||
config::{AgentConfig, SafetyConfig, SkillsConfig},
|
||||
context::ContextManager,
|
||||
hooks::HookRegistry,
|
||||
safety::SafetyLayer,
|
||||
tools::ToolRegistry,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_truncate_short_input() {
|
||||
@@ -1562,183 +1483,4 @@ mod tests {
|
||||
|
||||
assert!(should_fallback_routine_notification(&error)); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_telegram_bot_token_messages() {
|
||||
let detected = detect_sensitive_chat_credential("123456789:AABBccDDeeFFgg_Test-Token");
|
||||
assert_eq!(detected, Some(SensitiveChatCredential::TelegramBotToken));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_normal_telegram_setup_messages() {
|
||||
let detected = detect_sensitive_chat_credential(
|
||||
"Can you help me connect Telegram without sharing the token here?",
|
||||
);
|
||||
assert_eq!(detected, None);
|
||||
}
|
||||
|
||||
async fn make_gateway_test_agent(
|
||||
llm: Arc<StubLlm>,
|
||||
) -> (Agent, Arc<std::sync::Mutex<Vec<StatusUpdate>>>) {
|
||||
let llm_provider: Arc<dyn crate::llm::LlmProvider> = llm;
|
||||
let (stub, _sender) = StubChannel::new("gateway");
|
||||
let statuses = stub.captured_statuses_handle();
|
||||
let channel_manager = ChannelManager::new();
|
||||
channel_manager.add(Box::new(stub)).await;
|
||||
|
||||
let deps = AgentDeps {
|
||||
owner_id: "default".to_string(),
|
||||
store: None,
|
||||
llm: llm_provider,
|
||||
cheap_llm: None,
|
||||
safety: Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
})),
|
||||
tools: Arc::new(ToolRegistry::new()),
|
||||
workspace: None,
|
||||
extension_manager: None,
|
||||
skill_registry: None,
|
||||
skill_catalog: None,
|
||||
skills_config: SkillsConfig::default(),
|
||||
hooks: Arc::new(HookRegistry::new()),
|
||||
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
|
||||
sse_tx: None,
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||
builder: None,
|
||||
};
|
||||
|
||||
let agent = Agent::new(
|
||||
AgentConfig {
|
||||
name: "test-agent".to_string(),
|
||||
max_parallel_jobs: 1,
|
||||
job_timeout: Duration::from_secs(60),
|
||||
stuck_threshold: Duration::from_secs(60),
|
||||
repair_check_interval: Duration::from_secs(30),
|
||||
max_repair_attempts: 1,
|
||||
use_planning: false,
|
||||
session_idle_timeout: Duration::from_secs(300),
|
||||
allow_local_tools: false,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_tool_iterations: 5,
|
||||
auto_approve_tools: false,
|
||||
default_timezone: "UTC".to_string(),
|
||||
max_tokens_per_job: 0,
|
||||
},
|
||||
deps,
|
||||
Arc::new(channel_manager),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(Arc::new(ContextManager::new(1))),
|
||||
None,
|
||||
);
|
||||
|
||||
(agent, statuses)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_bot_token_messages_are_redirected_before_llm() {
|
||||
let llm = Arc::new(StubLlm::new("this should never be used"));
|
||||
let llm_handle = Arc::clone(&llm);
|
||||
let (agent, statuses) = make_gateway_test_agent(llm).await;
|
||||
let message = IncomingMessage::new(
|
||||
"gateway",
|
||||
"test-user",
|
||||
"123456789:AABBccDDeeFFgg_Test-Token",
|
||||
);
|
||||
|
||||
let response = agent
|
||||
.handle_message(&message)
|
||||
.await
|
||||
.expect("handle_message");
|
||||
|
||||
assert_eq!(
|
||||
response.as_deref(),
|
||||
Some(
|
||||
"Telegram bot tokens can't be accepted in normal chat. Use the secure Telegram setup flow instead."
|
||||
)
|
||||
);
|
||||
assert_eq!(llm_handle.calls(), 0, "LLM should not see raw bot tokens");
|
||||
|
||||
let statuses = statuses.lock().expect("poisoned");
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert!(matches!(
|
||||
&statuses[0],
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name,
|
||||
instructions,
|
||||
auth_url: None,
|
||||
setup_url: None,
|
||||
} if extension_name == "telegram"
|
||||
&& instructions.as_deref()
|
||||
== Some(
|
||||
"Telegram bot tokens can't be accepted in normal chat. Use the secure Telegram setup flow instead."
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_bot_token_messages_still_flow_through_pending_auth_mode() {
|
||||
let llm = Arc::new(StubLlm::new("this should never be used"));
|
||||
let llm_handle = Arc::clone(&llm);
|
||||
let (agent, statuses) = make_gateway_test_agent(llm).await;
|
||||
let thread_id = Uuid::new_v4();
|
||||
let session = agent
|
||||
.session_manager
|
||||
.get_or_create_session("test-user")
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let mut thread = Thread::with_id(thread_id, sess.id);
|
||||
thread.enter_auth_mode("telegram".to_string());
|
||||
sess.threads.insert(thread_id, thread);
|
||||
sess.active_thread = Some(thread_id);
|
||||
}
|
||||
|
||||
agent
|
||||
.session_manager
|
||||
.register_thread("test-user", "gateway", thread_id, Arc::clone(&session))
|
||||
.await;
|
||||
|
||||
let message = IncomingMessage::new(
|
||||
"gateway",
|
||||
"test-user",
|
||||
"123456789:AABBccDDeeFFgg_Test-Token",
|
||||
)
|
||||
.with_thread(thread_id.to_string());
|
||||
|
||||
let response = agent
|
||||
.handle_message(&message)
|
||||
.await
|
||||
.expect("handle_message");
|
||||
|
||||
assert_eq!(
|
||||
response.as_deref(),
|
||||
Some("Extension manager not available."),
|
||||
"pending auth should consume the token instead of treating it as normal chat"
|
||||
);
|
||||
assert_eq!(llm_handle.calls(), 0, "LLM should not see auth-mode tokens");
|
||||
|
||||
let statuses = statuses.lock().expect("poisoned");
|
||||
assert!(
|
||||
statuses.is_empty(),
|
||||
"no redirect status should be emitted when auth mode consumes the token"
|
||||
);
|
||||
|
||||
let sess = session.lock().await;
|
||||
let pending_auth = sess
|
||||
.threads
|
||||
.get(&thread_id)
|
||||
.and_then(|thread| thread.pending_auth.as_ref());
|
||||
assert!(
|
||||
pending_auth.is_none(),
|
||||
"auth mode should be cleared after the token is processed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,18 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(extension_manager) = self.deps.extension_manager.as_ref() {
|
||||
match extension_manager.llm_extension_state_summary().await {
|
||||
Ok(Some(summary)) => {
|
||||
reasoning = reasoning.with_extension_state_summary(summary);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::debug!("Could not load extension state summary: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(prompt) = system_prompt {
|
||||
reasoning = reasoning.with_system_prompt(prompt);
|
||||
}
|
||||
|
||||
@@ -1498,6 +1498,52 @@ impl ExtensionManager {
|
||||
Ok(extensions)
|
||||
}
|
||||
|
||||
/// Build a compact, deterministic extension snapshot for LLM prompt context.
|
||||
pub async fn llm_extension_state_summary(&self) -> Result<Option<String>, ExtensionError> {
|
||||
let mut extensions = self.list(None, false).await?;
|
||||
extensions.sort_by(|a, b| {
|
||||
llm_extension_sort_key(a.kind)
|
||||
.cmp(&llm_extension_sort_key(b.kind))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
let mut channels = Vec::new();
|
||||
let mut tools = Vec::new();
|
||||
let mut servers = Vec::new();
|
||||
|
||||
for extension in extensions {
|
||||
let owner_bound = matches!(extension.kind, ExtensionKind::WasmChannel)
|
||||
&& self.has_wasm_channel_owner_binding(&extension.name).await;
|
||||
if !(extension.active || extension.authenticated || owner_bound) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item = llm_extension_summary_item(&extension, owner_bound);
|
||||
match extension.kind {
|
||||
ExtensionKind::WasmChannel | ExtensionKind::ChannelRelay => channels.push(item),
|
||||
ExtensionKind::WasmTool => tools.push(item),
|
||||
ExtensionKind::McpServer => servers.push(item),
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
if !channels.is_empty() {
|
||||
lines.push(format!("- Channels: {}", channels.join("; ")));
|
||||
}
|
||||
if !tools.is_empty() {
|
||||
lines.push(format!("- Tools: {}", tools.join("; ")));
|
||||
}
|
||||
if !servers.is_empty() {
|
||||
lines.push(format!("- MCP servers: {}", servers.join("; ")));
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(lines.join("\n")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an installed extension.
|
||||
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
|
||||
Self::validate_extension_name(name)?;
|
||||
@@ -5613,6 +5659,40 @@ fn combine_install_errors(
|
||||
}
|
||||
}
|
||||
|
||||
fn llm_extension_sort_key(kind: ExtensionKind) -> u8 {
|
||||
match kind {
|
||||
ExtensionKind::WasmChannel | ExtensionKind::ChannelRelay => 0,
|
||||
ExtensionKind::WasmTool => 1,
|
||||
ExtensionKind::McpServer => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn llm_extension_summary_item(extension: &InstalledExtension, owner_bound: bool) -> String {
|
||||
let mut states = Vec::new();
|
||||
if extension.authenticated {
|
||||
states.push("authenticated".to_string());
|
||||
}
|
||||
if extension.active {
|
||||
states.push("active".to_string());
|
||||
} else if extension.authenticated {
|
||||
states.push("inactive".to_string());
|
||||
}
|
||||
if owner_bound {
|
||||
states.push("owner-bound".to_string());
|
||||
}
|
||||
if !extension.tools.is_empty() {
|
||||
let mut tool_names = extension.tools.clone();
|
||||
tool_names.sort();
|
||||
states.push(format!("tools: {}", tool_names.join(", ")));
|
||||
}
|
||||
|
||||
if states.is_empty() {
|
||||
extension.name.clone()
|
||||
} else {
|
||||
format!("{} ({})", extension.name, states.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt::Debug;
|
||||
@@ -6571,6 +6651,142 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
#[tokio::test]
|
||||
async fn test_llm_extension_state_summary_reports_active_owner_bound_telegram()
|
||||
-> Result<(), String> {
|
||||
let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?;
|
||||
let channels_dir = dir.path().join("channels");
|
||||
std::fs::create_dir_all(&channels_dir).map_err(|err| format!("channels dir: {err}"))?;
|
||||
std::fs::write(channels_dir.join("telegram.wasm"), b"mock")
|
||||
.map_err(|err| format!("write wasm: {err}"))?;
|
||||
std::fs::write(
|
||||
channels_dir.join("telegram.capabilities.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"type": "channel",
|
||||
"name": "telegram",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "telegram_bot_token",
|
||||
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||
"optional": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/telegram"]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"owner_id": null
|
||||
}
|
||||
}))
|
||||
.map_err(|err| format!("serialize capabilities: {err}"))?,
|
||||
)
|
||||
.map_err(|err| format!("write capabilities: {err}"))?;
|
||||
|
||||
let (db, _db_tmp) = crate::testing::test_db().await;
|
||||
let manager = {
|
||||
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
||||
use crate::testing::credentials::TEST_CRYPTO_KEY;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::mcp::process::McpProcessManager;
|
||||
use crate::tools::mcp::session::McpSessionManager;
|
||||
|
||||
let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string());
|
||||
let crypto = Arc::new(
|
||||
SecretsCrypto::new(master_key)
|
||||
.map_err(|err| format!("failed to construct test crypto: {err}"))?,
|
||||
);
|
||||
|
||||
ExtensionManager::new(
|
||||
Arc::new(McpSessionManager::new()),
|
||||
Arc::new(McpProcessManager::new()),
|
||||
Arc::new(InMemorySecretsStore::new(crypto)),
|
||||
Arc::new(ToolRegistry::new()),
|
||||
None,
|
||||
None,
|
||||
dir.path().join("tools"),
|
||||
channels_dir.clone(),
|
||||
None,
|
||||
"test".to_string(),
|
||||
Some(db),
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
|
||||
let channel_manager = Arc::new(ChannelManager::new());
|
||||
let runtime = Arc::new(
|
||||
WasmChannelRuntime::new(WasmChannelRuntimeConfig::for_testing())
|
||||
.map_err(|err| format!("runtime: {err}"))?,
|
||||
);
|
||||
let pairing_store = Arc::new(PairingStore::with_base_dir(
|
||||
dir.path().join("pairing-state"),
|
||||
));
|
||||
let router = Arc::new(WasmChannelRouter::new());
|
||||
manager
|
||||
.set_channel_runtime(
|
||||
Arc::clone(&channel_manager),
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&pairing_store),
|
||||
Arc::clone(&router),
|
||||
std::collections::HashMap::new(),
|
||||
)
|
||||
.await;
|
||||
manager
|
||||
.set_test_wasm_channel_loader(Arc::new({
|
||||
let runtime = Arc::clone(&runtime);
|
||||
let pairing_store = Arc::clone(&pairing_store);
|
||||
move |name| {
|
||||
Ok(make_test_loaded_channel(
|
||||
Arc::clone(&runtime),
|
||||
name,
|
||||
Arc::clone(&pairing_store),
|
||||
))
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
manager
|
||||
.set_test_telegram_binding_resolver(Arc::new(|_token, existing_owner_id| {
|
||||
if existing_owner_id.is_some() {
|
||||
return Err(ExtensionError::Other(
|
||||
"owner binding should be derived during setup".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(TelegramBindingResult::Bound(TelegramBindingData {
|
||||
owner_id: 424242,
|
||||
bot_username: Some("test_hot_bot".to_string()),
|
||||
binding_state: TelegramOwnerBindingState::VerifiedNow,
|
||||
}))
|
||||
}))
|
||||
.await;
|
||||
|
||||
manager
|
||||
.configure(
|
||||
"telegram",
|
||||
&std::collections::HashMap::from([(
|
||||
"telegram_bot_token".to_string(),
|
||||
"123456789:ABCdefGhI".to_string(),
|
||||
)]),
|
||||
&std::collections::HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("configure succeeds: {err}"))?;
|
||||
|
||||
let summary = manager
|
||||
.llm_extension_state_summary()
|
||||
.await
|
||||
.map_err(|err| format!("summary: {err}"))?
|
||||
.ok_or_else(|| "expected extension summary".to_string())?;
|
||||
|
||||
require(
|
||||
summary.contains("- Channels: telegram (authenticated, active, owner-bound)"),
|
||||
format!("unexpected summary: {summary}"),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_telegram_hot_activation_returns_verification_challenge_before_binding()
|
||||
-> Result<(), String> {
|
||||
|
||||
+92
-12
@@ -353,6 +353,8 @@ pub struct Reasoning {
|
||||
workspace_system_prompt: Option<String>,
|
||||
/// Optional skill context block to inject into system prompt.
|
||||
skill_context: Option<String>,
|
||||
/// Optional snapshot of connected/active extensions for the current user.
|
||||
extension_state_summary: Option<String>,
|
||||
/// Channel name (e.g. "discord", "telegram") for formatting hints.
|
||||
channel: Option<String>,
|
||||
/// Model name for runtime context.
|
||||
@@ -371,6 +373,7 @@ impl Reasoning {
|
||||
llm,
|
||||
workspace_system_prompt: None,
|
||||
skill_context: None,
|
||||
extension_state_summary: None,
|
||||
channel: None,
|
||||
model_name: None,
|
||||
is_group_chat: false,
|
||||
@@ -400,6 +403,14 @@ impl Reasoning {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set extension runtime context to inject into the system prompt.
|
||||
pub fn with_extension_state_summary(mut self, summary: String) -> Self {
|
||||
if !summary.is_empty() {
|
||||
self.extension_state_summary = Some(summary);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the channel name for channel-specific formatting hints.
|
||||
pub fn with_channel(mut self, channel: impl Into<String>) -> Self {
|
||||
let ch = channel.into();
|
||||
@@ -932,21 +943,54 @@ Example:
|
||||
}
|
||||
|
||||
fn build_extensions_section_for_tools(&self, tools: &[ToolDefinition]) -> String {
|
||||
// Only include when the extension management tools are available
|
||||
let has_ext_tools = tools.iter().any(|t| t.name == "tool_search");
|
||||
if !has_ext_tools {
|
||||
let has_search = tools.iter().any(|t| t.name == "tool_search");
|
||||
let has_list = tools.iter().any(|t| t.name == "tool_list");
|
||||
let has_info = tools.iter().any(|t| t.name == "extension_info");
|
||||
|
||||
if self.extension_state_summary.is_none() && !has_search && !has_list && !has_info {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
"\n\n## Extensions\n\
|
||||
You can search, install, and activate extensions to add new capabilities:\n\
|
||||
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
|
||||
When users ask about connecting a messaging platform, search for it as a channel.\n\
|
||||
- **Tools** — sandboxed functions that extend your abilities.\n\
|
||||
- **MCP servers** — external API integrations via the Model Context Protocol.\n\n\
|
||||
Use `tool_search` to find extensions by name. Refer to them by their kind \
|
||||
(channel, tool, or server) — not as \"MCP server\" generically."
|
||||
.to_string()
|
||||
let mut blocks = Vec::new();
|
||||
if let Some(ref summary) = self.extension_state_summary {
|
||||
blocks.push(format!(
|
||||
"Current extension state for this user:\n{}",
|
||||
summary
|
||||
));
|
||||
}
|
||||
|
||||
if has_search || has_list || has_info {
|
||||
let mut guidance = String::from(
|
||||
"You can search, install, and activate extensions to add new capabilities:\n\
|
||||
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
|
||||
When users ask about connecting a messaging platform, search for it as a channel.\n\
|
||||
- **Tools** — sandboxed functions that extend your abilities.\n\
|
||||
- **MCP servers** — external API integrations via the Model Context Protocol.",
|
||||
);
|
||||
|
||||
if has_list {
|
||||
guidance.push_str(
|
||||
"\n\nBefore telling the user to connect, activate, or re-enable an extension, \
|
||||
inspect the current state with `tool_list`.",
|
||||
);
|
||||
}
|
||||
if has_info {
|
||||
guidance.push_str(
|
||||
"\nUse `extension_info` when you need deeper compatibility or runtime details \
|
||||
for an installed extension.",
|
||||
);
|
||||
}
|
||||
if has_search {
|
||||
guidance.push_str(
|
||||
"\nUse `tool_search` to find extensions by name. Refer to them by their kind \
|
||||
(channel, tool, or server) — not as \"MCP server\" generically.",
|
||||
);
|
||||
}
|
||||
|
||||
blocks.push(guidance);
|
||||
}
|
||||
|
||||
format!("\n\n## Extensions\n{}", blocks.join("\n\n"))
|
||||
}
|
||||
|
||||
fn build_channel_section(&self) -> String {
|
||||
@@ -2285,6 +2329,42 @@ That's my plan."#;
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_includes_extension_runtime_summary() {
|
||||
let reasoning = make_test_reasoning().with_extension_state_summary(
|
||||
"- Channels: telegram (authenticated, active, owner-bound)".to_string(),
|
||||
);
|
||||
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&[]);
|
||||
assert!(
|
||||
prompt.contains("## Extensions"),
|
||||
"Prompt should contain an Extensions section when runtime state is present"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("telegram (authenticated, active, owner-bound)"),
|
||||
"Prompt should include the injected extension runtime summary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_extension_guidance_prefers_inspection_before_reconnect() {
|
||||
let reasoning = make_test_reasoning();
|
||||
let prompt = reasoning.build_system_prompt_with_tools(&make_tools(&[
|
||||
"tool_search",
|
||||
"tool_list",
|
||||
"extension_info",
|
||||
]));
|
||||
|
||||
assert!(
|
||||
prompt.contains("inspect the current state with `tool_list`"),
|
||||
"Prompt should direct the model to inspect installed/active state first"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Use `extension_info` when you need deeper compatibility"),
|
||||
"Prompt should mention extension_info for deeper extension details"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,10 +35,6 @@ If they're interested, set it up right here using the extension tools:
|
||||
3. Use `tool_auth` to collect credentials (e.g. Telegram bot token from @BotFather)
|
||||
4. The channel will be hot-activated — no restart needed
|
||||
|
||||
Never ask the user to paste tokens, passwords, API keys, or other secrets into
|
||||
normal chat. If an extension has a secure auth/setup flow, always use that flow
|
||||
and keep the secret out of the conversation history.
|
||||
|
||||
Don't push if they're not interested — note their preference and move on.
|
||||
|
||||
## Step 3: Save What You Learned (MANDATORY after 3 user messages)
|
||||
|
||||
Reference in New Issue
Block a user