mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 06:32:17 +00:00
bugfix batch
This commit is contained in:
Generated
+14
-14
@@ -3866,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3903,7 +3903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3934,7 +3934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3961,7 +3961,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3987,7 +3987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4015,7 +4015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4032,7 +4032,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4068,7 +4068,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4087,7 +4087,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4106,7 +4106,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4138,7 +4138,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4161,7 +4161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4180,7 +4180,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -8802,7 +8802,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.18"
|
||||
version = "0.3.19"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.19"
|
||||
version = "0.3.20"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -6333,21 +6333,6 @@ pub async fn set_provider_key(
|
||||
Path(name): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
// Validate provider name against known list
|
||||
{
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if catalog.get_provider(&name).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let key = match body["key"].as_str() {
|
||||
Some(k) if !k.trim().is_empty() => k.trim().to_string(),
|
||||
_ => {
|
||||
@@ -6358,6 +6343,7 @@ pub async fn set_provider_key(
|
||||
}
|
||||
};
|
||||
|
||||
// Look up env var from catalog; for unknown/custom providers derive one.
|
||||
let env_var = {
|
||||
let catalog = state
|
||||
.kernel
|
||||
@@ -6367,16 +6353,15 @@ pub async fn set_provider_key(
|
||||
catalog
|
||||
.get_provider(&name)
|
||||
.map(|p| p.api_key_env.clone())
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_else(|| {
|
||||
// Custom provider — derive env var: MY_PROVIDER → MY_PROVIDER_API_KEY
|
||||
format!(
|
||||
"{}_API_KEY",
|
||||
name.to_uppercase().replace('-', "_")
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
if env_var.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Provider does not require an API key"})),
|
||||
);
|
||||
}
|
||||
|
||||
// Write to secrets.env file
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if let Err(e) = write_secret_env(&secrets_path, &env_var, &key) {
|
||||
@@ -6563,22 +6548,7 @@ pub async fn set_provider_url(
|
||||
Path(name): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
// Validate provider exists
|
||||
let provider_exists = {
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
catalog.get_provider(&name).is_some()
|
||||
};
|
||||
if !provider_exists {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})),
|
||||
);
|
||||
}
|
||||
|
||||
// Accept any provider name — custom providers are supported via OpenAI-compatible format.
|
||||
let base_url = match body["base_url"].as_str() {
|
||||
Some(u) if !u.trim().is_empty() => u.trim().to_string(),
|
||||
_ => {
|
||||
|
||||
@@ -5274,6 +5274,59 @@ impl KernelHandle for OpenFangKernel {
|
||||
Ok(format!("Message sent to {} via {}", recipient, channel))
|
||||
}
|
||||
|
||||
async fn send_channel_media(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
media_type: &str,
|
||||
media_url: &str,
|
||||
caption: Option<&str>,
|
||||
filename: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let adapter = self
|
||||
.channel_adapters
|
||||
.get(channel)
|
||||
.ok_or_else(|| {
|
||||
let available: Vec<String> = self
|
||||
.channel_adapters
|
||||
.iter()
|
||||
.map(|e| e.key().clone())
|
||||
.collect();
|
||||
format!(
|
||||
"Channel '{}' not found. Available channels: {:?}",
|
||||
channel, available
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let user = openfang_channels::types::ChannelUser {
|
||||
platform_id: recipient.to_string(),
|
||||
display_name: recipient.to_string(),
|
||||
openfang_user: None,
|
||||
};
|
||||
|
||||
let content = match media_type {
|
||||
"image" => openfang_channels::types::ChannelContent::Image {
|
||||
url: media_url.to_string(),
|
||||
caption: caption.map(|s| s.to_string()),
|
||||
},
|
||||
"file" => openfang_channels::types::ChannelContent::File {
|
||||
url: media_url.to_string(),
|
||||
filename: filename.unwrap_or("file").to_string(),
|
||||
},
|
||||
_ => {
|
||||
return Err(format!("Unsupported media type: '{media_type}'. Use 'image' or 'file'."));
|
||||
}
|
||||
};
|
||||
|
||||
adapter
|
||||
.send(&user, content)
|
||||
.await
|
||||
.map_err(|e| format!("Channel media send failed: {e}"))?;
|
||||
|
||||
Ok(format!("{} sent to {} via {}", media_type, recipient, channel))
|
||||
}
|
||||
|
||||
async fn spawn_agent_checked(
|
||||
&self,
|
||||
manifest_toml: &str,
|
||||
|
||||
@@ -194,6 +194,21 @@ pub trait KernelHandle: Send + Sync {
|
||||
Err("Channel send not available".to_string())
|
||||
}
|
||||
|
||||
/// Send media content (image/file) to a user on a named channel adapter.
|
||||
/// `media_type` is "image" or "file", `media_url` is the URL, `caption` is optional text.
|
||||
async fn send_channel_media(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
media_type: &str,
|
||||
media_url: &str,
|
||||
caption: Option<&str>,
|
||||
filename: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let _ = (channel, recipient, media_type, media_url, caption, filename);
|
||||
Err("Channel media send not available".to_string())
|
||||
}
|
||||
|
||||
/// Spawn an agent with capability inheritance enforcement.
|
||||
/// `parent_caps` are the parent's granted capabilities. The kernel MUST verify
|
||||
/// that every capability in the child manifest is covered by `parent_caps`.
|
||||
|
||||
@@ -162,14 +162,27 @@ impl ModelCatalog {
|
||||
p.base_url = url.to_string();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
// Custom provider — add a new entry so it appears in /api/providers
|
||||
let env_var = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
|
||||
self.providers.push(ProviderInfo {
|
||||
id: provider.to_string(),
|
||||
display_name: provider.to_string(),
|
||||
api_key_env: env_var,
|
||||
base_url: url.to_string(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
});
|
||||
// Re-detect auth for the newly added provider
|
||||
self.detect_auth();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a batch of provider URL overrides from config.
|
||||
///
|
||||
/// Each entry maps a provider ID to a custom base URL.
|
||||
/// Unknown providers are silently skipped.
|
||||
/// Unknown providers are automatically added as custom OpenAI-compatible entries.
|
||||
/// Providers with explicit URL overrides are marked as configured since
|
||||
/// the user intentionally set them up (e.g. local proxies, custom endpoints).
|
||||
pub fn apply_url_overrides(&mut self, overrides: &HashMap<String, String>) {
|
||||
@@ -3361,8 +3374,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_set_provider_url_unknown() {
|
||||
let mut catalog = ModelCatalog::new();
|
||||
let updated = catalog.set_provider_url("nonexistent", "http://localhost:9999");
|
||||
assert!(!updated);
|
||||
let initial_count = catalog.list_providers().len();
|
||||
let updated = catalog.set_provider_url("my-custom-llm", "http://localhost:9999");
|
||||
// Unknown providers are now auto-registered as custom entries
|
||||
assert!(updated);
|
||||
assert_eq!(catalog.list_providers().len(), initial_count + 1);
|
||||
assert_eq!(
|
||||
catalog.get_provider("my-custom-llm").unwrap().base_url,
|
||||
"http://localhost:9999"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -996,16 +996,19 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
// --- Channel send tool (proactive outbound messaging) ---
|
||||
ToolDefinition {
|
||||
name: "channel_send".to_string(),
|
||||
description: "Send a message to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally prefix the message with 'Subject: Your Subject\\n\\n' to set the email subject.".to_string(),
|
||||
description: "Send a message or media to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally set subject. For media: set image_url or file_url to send an image or file instead of (or alongside) text.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": { "type": "string", "description": "Channel adapter name (e.g., 'email', 'telegram', 'slack', 'discord')" },
|
||||
"recipient": { "type": "string", "description": "Platform-specific recipient identifier (email address, user ID, etc.)" },
|
||||
"subject": { "type": "string", "description": "Optional subject line (used for email; ignored for other channels)" },
|
||||
"message": { "type": "string", "description": "The message body to send" }
|
||||
"message": { "type": "string", "description": "The message body to send (required for text, optional caption for media)" },
|
||||
"image_url": { "type": "string", "description": "URL of an image to send (supported on Telegram, Discord, Slack)" },
|
||||
"file_url": { "type": "string", "description": "URL of a file to send as attachment" },
|
||||
"filename": { "type": "string", "description": "Filename for file attachments (defaults to 'file')" }
|
||||
},
|
||||
"required": ["channel", "recipient", "message"]
|
||||
"required": ["channel", "recipient"]
|
||||
}),
|
||||
},
|
||||
// --- Hand tools (curated autonomous capability packages) ---
|
||||
@@ -2119,24 +2122,44 @@ async fn tool_channel_send(
|
||||
.as_str()
|
||||
.ok_or("Missing 'recipient' parameter")?
|
||||
.trim();
|
||||
let message = input["message"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'message' parameter")?;
|
||||
|
||||
if recipient.is_empty() {
|
||||
return Err("Recipient cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for media content (image_url or file_url)
|
||||
let image_url = input["image_url"].as_str().filter(|s| !s.is_empty());
|
||||
let file_url = input["file_url"].as_str().filter(|s| !s.is_empty());
|
||||
|
||||
if let Some(url) = image_url {
|
||||
let caption = input["message"].as_str().filter(|s| !s.is_empty());
|
||||
return kh
|
||||
.send_channel_media(&channel, recipient, "image", url, caption, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(url) = file_url {
|
||||
let caption = input["message"].as_str().filter(|s| !s.is_empty());
|
||||
let filename = input["filename"].as_str();
|
||||
return kh
|
||||
.send_channel_media(&channel, recipient, "file", url, caption, filename)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Text-only message
|
||||
let message = input["message"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'message' parameter (required for text messages)")?;
|
||||
|
||||
if message.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// For email channels, validate email format and prepend subject
|
||||
let final_message = if channel == "email" {
|
||||
// Basic email format validation
|
||||
if !recipient.contains('@') || !recipient.contains('.') {
|
||||
return Err(format!("Invalid email address: '{recipient}'"));
|
||||
}
|
||||
// Prepend subject if provided
|
||||
if let Some(subject) = input["subject"].as_str() {
|
||||
if !subject.is_empty() {
|
||||
format!("Subject: {subject}\n\n{message}")
|
||||
|
||||
Reference in New Issue
Block a user