bugfix batch

This commit is contained in:
jaberjaber23
2026-03-05 15:27:10 +03:00
parent 05431509be
commit eafeb6a012
7 changed files with 147 additions and 66 deletions
Generated
+14 -14
View File
@@ -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
View File
@@ -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"
+9 -39
View File
@@ -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(),
_ => {
+53
View File
@@ -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`.
+24 -4
View File
@@ -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]
+31 -8
View File
@@ -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}")