diff --git a/Cargo.lock b/Cargo.lock
index cfbf154b..0e58a826 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3875,7 +3875,7 @@ dependencies = [
[[package]]
name = "openfang-api"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"async-trait",
"axum",
@@ -3912,7 +3912,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"async-trait",
"axum",
@@ -3944,7 +3944,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"clap",
"clap_complete",
@@ -3971,7 +3971,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"axum",
"open",
@@ -3997,7 +3997,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"aes-gcm",
"argon2",
@@ -4025,7 +4025,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"chrono",
"dashmap",
@@ -4042,7 +4042,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"async-trait",
"chrono",
@@ -4078,7 +4078,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"async-trait",
"chrono",
@@ -4097,7 +4097,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4116,7 +4116,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"anyhow",
"async-trait",
@@ -4148,7 +4148,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"chrono",
"hex",
@@ -4171,7 +4171,7 @@ dependencies = [
[[package]]
name = "openfang-types"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"async-trait",
"chrono",
@@ -4190,7 +4190,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
-version = "0.3.24"
+version = "0.3.25"
dependencies = [
"async-trait",
"chrono",
@@ -8818,7 +8818,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
-version = "0.3.24"
+version = "0.3.25"
[[package]]
name = "yoke"
diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs
index b2785da9..be7e720b 100644
--- a/crates/openfang-api/src/middleware.rs
+++ b/crates/openfang-api/src/middleware.rs
@@ -196,6 +196,10 @@ pub async fn security_headers(request: Request
, next: Next) -> Response>,
+ Path(id): Path,
+ Json(body): Json,
+) -> impl IntoResponse {
+ let agent_id: AgentId = match id.parse() {
+ Ok(id) => id,
+ Err(_) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({"error": "Invalid agent ID"})),
+ );
+ }
+ };
+
+ if state.kernel.registry.get(agent_id).is_none() {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(serde_json::json!({"error": "Agent not found"})),
+ );
+ }
+
+ // Apply partial updates using dedicated registry methods
+ if let Some(name) = body.get("name").and_then(|v| v.as_str()) {
+ if let Err(e) = state
+ .kernel
+ .registry
+ .update_name(agent_id, name.to_string())
+ {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({"error": format!("{e}")})),
+ );
+ }
+ }
+ if let Some(desc) = body.get("description").and_then(|v| v.as_str()) {
+ if let Err(e) = state
+ .kernel
+ .registry
+ .update_description(agent_id, desc.to_string())
+ {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({"error": format!("{e}")})),
+ );
+ }
+ }
+ if let Some(model) = body.get("model").and_then(|v| v.as_str()) {
+ if let Err(e) = state.kernel.set_agent_model(agent_id, model) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({"error": format!("{e}")})),
+ );
+ }
+ }
+ if let Some(system_prompt) = body.get("system_prompt").and_then(|v| v.as_str()) {
+ if let Err(e) = state
+ .kernel
+ .registry
+ .update_system_prompt(agent_id, system_prompt.to_string())
+ {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({"error": format!("{e}")})),
+ );
+ }
+ }
+
+ // Persist updated entry to SQLite
+ if let Some(entry) = state.kernel.registry.get(agent_id) {
+ let _ = state.kernel.memory.save_agent(&entry);
+ (
+ StatusCode::OK,
+ Json(serde_json::json!({"status": "ok", "agent_id": entry.id.to_string(), "name": entry.name})),
+ )
+ } else {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(serde_json::json!({"error": "Agent vanished during update"})),
+ )
+ }
+}
+
// ---------------------------------------------------------------------------
// Migration endpoint
// ---------------------------------------------------------------------------
@@ -6113,6 +6197,12 @@ pub async fn clear_agent_history(
)
}
};
+ if state.kernel.registry.get(agent_id).is_none() {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(serde_json::json!({"error": "Agent not found"})),
+ );
+ }
match state.kernel.clear_agent_history(agent_id) {
Ok(()) => (
StatusCode::OK,
diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs
index 11393184..a87b2519 100644
--- a/crates/openfang-api/src/server.rs
+++ b/crates/openfang-api/src/server.rs
@@ -126,7 +126,7 @@ pub async fn build_router(
)
.route(
"/api/agents/{id}",
- axum::routing::get(routes::get_agent).delete(routes::kill_agent),
+ axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
)
.route(
"/api/agents/{id}/mode",
diff --git a/crates/openfang-api/static/css/components.css b/crates/openfang-api/static/css/components.css
index 4d03b633..755a28c7 100644
--- a/crates/openfang-api/static/css/components.css
+++ b/crates/openfang-api/static/css/components.css
@@ -69,6 +69,32 @@
gap: 16px;
}
+/* Card-based flex containers for agent chips and similar inline layouts */
+.card-flex {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+/* Nested list indentation inside cards, detail panels, and modals */
+.card ul, .card ol,
+.detail-grid ul, .detail-grid ol,
+.modal ul, .modal ol,
+.info-card ul, .info-card ol {
+ padding-left: 18px;
+ margin: 4px 0;
+}
+.card ul ul, .card ol ol,
+.modal ul ul, .modal ol ol {
+ padding-left: 16px;
+ margin: 2px 0;
+}
+.card li, .modal li, .info-card li {
+ margin-bottom: 2px;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
/* Glow effect on card hover */
.card-glow {
overflow: hidden;
@@ -90,13 +116,17 @@
display: inline-flex;
align-items: center;
gap: 4px;
- padding: 2px 8px;
+ padding: 3px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
+ white-space: nowrap;
+ line-height: 1.2;
+ vertical-align: middle;
}
+.badge + .badge { margin-left: 4px; }
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
@@ -110,7 +140,7 @@
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
-.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
+.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
.text-danger { color: var(--error); }
/* Tables */
@@ -949,6 +979,14 @@ mark.search-highlight {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
+.model-switcher-search select {
+ max-width: 100px;
+ flex-shrink: 0;
+}
+.model-switcher-search select:focus {
+ outline: none;
+ border-color: var(--accent);
+}
.model-switcher-search input {
flex: 1;
background: none;
diff --git a/crates/openfang-api/static/index_body.html b/crates/openfang-api/static/index_body.html
index 6aeae4b8..4ed1f69d 100644
--- a/crates/openfang-api/static/index_body.html
+++ b/crates/openfang-api/static/index_body.html
@@ -769,6 +769,12 @@
+
+
diff --git a/crates/openfang-api/static/js/pages/chat.js b/crates/openfang-api/static/js/pages/chat.js
index f1c96d45..cd9fc57a 100644
--- a/crates/openfang-api/static/js/pages/chat.js
+++ b/crates/openfang-api/static/js/pages/chat.js
@@ -37,6 +37,7 @@ function chatPage() {
// Model switcher dropdown
showModelSwitcher: false,
modelSwitcherFilter: '',
+ modelSwitcherProviderFilter: '',
modelSwitcherIdx: 0,
modelSwitching: false,
_modelCache: null,
@@ -99,14 +100,25 @@ function chatPage() {
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
},
+ get switcherProviders() {
+ var seen = {};
+ (this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
+ return Object.keys(seen).sort();
+ },
+
get filteredSwitcherModels() {
var models = this._modelCache || [];
- if (!this.modelSwitcherFilter) return models;
- var f = this.modelSwitcherFilter.toLowerCase();
+ var provFilter = this.modelSwitcherProviderFilter;
+ var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
+ if (!provFilter && !textFilter) return models;
return models.filter(function(m) {
- return m.id.toLowerCase().indexOf(f) !== -1 ||
- (m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
- m.provider.toLowerCase().indexOf(f) !== -1;
+ if (provFilter && m.provider !== provFilter) return false;
+ if (textFilter) {
+ return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
+ (m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
+ m.provider.toLowerCase().indexOf(textFilter) !== -1;
+ }
+ return true;
});
},
@@ -220,6 +232,7 @@ function chatPage() {
var now = Date.now();
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
this.modelSwitcherFilter = '';
+ this.modelSwitcherProviderFilter = '';
this.modelSwitcherIdx = 0;
this.showModelSwitcher = true;
this.$nextTick(function() {
@@ -234,6 +247,7 @@ function chatPage() {
self._modelCacheTime = Date.now();
self.modelPickerList = models;
self.modelSwitcherFilter = '';
+ self.modelSwitcherProviderFilter = '';
self.modelSwitcherIdx = 0;
self.showModelSwitcher = true;
self.$nextTick(function() {
diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs
index 266e4aa0..88e0baf4 100644
--- a/crates/openfang-kernel/src/kernel.rs
+++ b/crates/openfang-kernel/src/kernel.rs
@@ -572,7 +572,12 @@ impl OpenFangKernel {
}
}
- // Add fallback providers to the chain
+ // Add fallback providers to the chain (with model names for cross-provider fallback)
+ let mut model_chain: Vec<(Arc, String)> = Vec::new();
+ // Primary driver uses empty model name (uses the request's model field as-is)
+ for d in &driver_chain {
+ model_chain.push((d.clone(), String::new()));
+ }
for fb in &config.fallback_providers {
let fb_config = DriverConfig {
provider: fb.provider.clone(),
@@ -593,7 +598,8 @@ impl OpenFangKernel {
model = %fb.model,
"Fallback provider configured"
);
- driver_chain.push(d);
+ driver_chain.push(d.clone());
+ model_chain.push((d, fb.model.clone()));
}
Err(e) => {
warn!(
@@ -607,8 +613,8 @@ impl OpenFangKernel {
// Use the chain, or create a stub driver if everything failed
let driver: Arc = if driver_chain.len() > 1 {
- Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new(
- driver_chain,
+ Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::with_models(
+ model_chain,
))
} else if let Some(single) = driver_chain.into_iter().next() {
single
diff --git a/crates/openfang-runtime/src/drivers/mod.rs b/crates/openfang-runtime/src/drivers/mod.rs
index 5a69938e..da3cf9ad 100644
--- a/crates/openfang-runtime/src/drivers/mod.rs
+++ b/crates/openfang-runtime/src/drivers/mod.rs
@@ -14,7 +14,8 @@ pub mod openai;
use crate::llm_driver::{DriverConfig, LlmDriver, LlmError};
use openfang_types::model_catalog::{
AI21_BASE_URL, ANTHROPIC_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL,
- FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL,
+ FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LEMONADE_BASE_URL,
+ LMSTUDIO_BASE_URL,
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
@@ -89,6 +90,11 @@ fn provider_defaults(provider: &str) -> Option {
api_key_env: "LMSTUDIO_API_KEY",
key_required: false,
}),
+ "lemonade" => Some(ProviderDefaults {
+ base_url: LEMONADE_BASE_URL,
+ api_key_env: "LEMONADE_API_KEY",
+ key_required: false,
+ }),
"perplexity" => Some(ProviderDefaults {
base_url: PERPLEXITY_BASE_URL,
api_key_env: "PERPLEXITY_API_KEY",
diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs
index 63c7cdfd..3dfacb6a 100644
--- a/crates/openfang-runtime/src/model_catalog.rs
+++ b/crates/openfang-runtime/src/model_catalog.rs
@@ -8,7 +8,8 @@ use openfang_types::model_catalog::{
BEDROCK_BASE_URL, CEREBRAS_BASE_URL, COHERE_BASE_URL, DEEPSEEK_BASE_URL, FIREWORKS_BASE_URL,
GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL,
LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL,
- OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
+ LEMONADE_BASE_URL, OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL,
+ QIANFAN_BASE_URL, QWEN_BASE_URL,
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VENICE_BASE_URL, VLLM_BASE_URL,
VOLCENGINE_BASE_URL, VOLCENGINE_CODING_BASE_URL, XAI_BASE_URL, ZAI_BASE_URL,
ZAI_CODING_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
@@ -499,6 +500,15 @@ fn builtin_providers() -> Vec {
auth_status: AuthStatus::NotRequired,
model_count: 0,
},
+ ProviderInfo {
+ id: "lemonade".into(),
+ display_name: "Lemonade".into(),
+ api_key_env: "LEMONADE_API_KEY".into(),
+ base_url: LEMONADE_BASE_URL.into(),
+ key_required: false,
+ auth_status: AuthStatus::NotRequired,
+ model_count: 0,
+ },
// ── New providers (8) ──────────────────────────────────────
ProviderInfo {
id: "perplexity".into(),
@@ -768,7 +778,7 @@ fn builtin_aliases() -> HashMap {
("qwen", "qwen-plus"),
("glm", "glm-5-20250605"),
("ernie", "ernie-4.5-8k"),
- ("kimi", "moonshot-v1-128k"),
+ ("kimi", "kimi-k2-0711"),
("minimax", "MiniMax-M2.5"),
("minimax-m2.5", "MiniMax-M2.5"),
("minimax-m2.1", "MiniMax-M2.1"),
@@ -2843,7 +2853,7 @@ fn builtin_models() -> Vec {
aliases: vec!["codegeex".into()],
},
// ══════════════════════════════════════════════════════════════
- // Moonshot / Kimi (3)
+ // Moonshot / Kimi (5)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "moonshot-v1-128k".into(),
@@ -2857,7 +2867,7 @@ fn builtin_models() -> Vec {
supports_tools: true,
supports_vision: false,
supports_streaming: true,
- aliases: vec!["kimi".into()],
+ aliases: vec![],
},
ModelCatalogEntry {
id: "moonshot-v1-32k".into(),
@@ -2887,6 +2897,34 @@ fn builtin_models() -> Vec {
supports_streaming: true,
aliases: vec![],
},
+ ModelCatalogEntry {
+ id: "kimi-k2-0711".into(),
+ display_name: "Kimi K2".into(),
+ provider: "moonshot".into(),
+ tier: ModelTier::Frontier,
+ context_window: 131_072,
+ max_output_tokens: 16_384,
+ input_cost_per_m: 2.00,
+ output_cost_per_m: 8.00,
+ supports_tools: true,
+ supports_vision: true,
+ supports_streaming: true,
+ aliases: vec!["kimi-k2".into()],
+ },
+ ModelCatalogEntry {
+ id: "kimi-k2.5-0711".into(),
+ display_name: "Kimi K2.5".into(),
+ provider: "moonshot".into(),
+ tier: ModelTier::Frontier,
+ context_window: 131_072,
+ max_output_tokens: 16_384,
+ input_cost_per_m: 2.00,
+ output_cost_per_m: 8.00,
+ supports_tools: true,
+ supports_vision: true,
+ supports_streaming: true,
+ aliases: vec!["kimi-k2.5".into()],
+ },
// ══════════════════════════════════════════════════════════════
// Baidu Qianfan / ERNIE (3)
// ══════════════════════════════════════════════════════════════
@@ -3243,7 +3281,7 @@ mod tests {
#[test]
fn test_catalog_has_providers() {
let catalog = ModelCatalog::new();
- assert_eq!(catalog.list_providers().len(), 35);
+ assert_eq!(catalog.list_providers().len(), 36);
}
#[test]
diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs
index 7f3c5397..f37db523 100644
--- a/crates/openfang-runtime/src/tool_runner.rs
+++ b/crates/openfang-runtime/src/tool_runner.rs
@@ -296,6 +296,9 @@ pub async fn execute_tool(
// Location tool
"location_get" => tool_location_get().await,
+ // System time tool
+ "system_time" => Ok(tool_system_time()),
+
// Cron scheduling tools
"cron_create" => tool_cron_create(input, kernel, caller_agent_id).await,
"cron_list" => tool_cron_list(kernel, caller_agent_id).await,
@@ -1177,6 +1180,16 @@ pub fn builtin_tool_definitions() -> Vec {
"properties": {}
}),
},
+ // --- System time tool ---
+ ToolDefinition {
+ name: "system_time".to_string(),
+ description: "Get the current date, time, and timezone. Returns ISO 8601 timestamp, Unix epoch seconds, and timezone info.".to_string(),
+ input_schema: serde_json::json!({
+ "type": "object",
+ "properties": {},
+ "required": []
+ }),
+ },
// --- Canvas / A2UI tool ---
ToolDefinition {
name: "canvas_present".to_string(),
@@ -2537,6 +2550,27 @@ async fn tool_location_get() -> Result {
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}"))
}
+// ---------------------------------------------------------------------------
+// System time tool
+// ---------------------------------------------------------------------------
+
+/// Return current date, time, timezone, and Unix epoch.
+fn tool_system_time() -> String {
+ let now_utc = chrono::Utc::now();
+ let now_local = chrono::Local::now();
+ let result = serde_json::json!({
+ "utc": now_utc.to_rfc3339(),
+ "local": now_local.to_rfc3339(),
+ "unix_epoch": now_utc.timestamp(),
+ "timezone": now_local.format("%Z").to_string(),
+ "utc_offset": now_local.format("%:z").to_string(),
+ "date": now_local.format("%Y-%m-%d").to_string(),
+ "time": now_local.format("%H:%M:%S").to_string(),
+ "day_of_week": now_local.format("%A").to_string(),
+ });
+ serde_json::to_string_pretty(&result).unwrap_or_else(|_| now_utc.to_rfc3339())
+}
+
// ---------------------------------------------------------------------------
// Media understanding tools
// ---------------------------------------------------------------------------
@@ -3114,6 +3148,7 @@ mod tests {
assert!(names.contains(&"schedule_delete"));
assert!(names.contains(&"image_analyze"));
assert!(names.contains(&"location_get"));
+ assert!(names.contains(&"system_time"));
// 6 browser tools
assert!(names.contains(&"browser_navigate"));
assert!(names.contains(&"browser_click"));
diff --git a/crates/openfang-skills/src/clawhub.rs b/crates/openfang-skills/src/clawhub.rs
index 6d1f4d1b..3f9129cb 100644
--- a/crates/openfang-skills/src/clawhub.rs
+++ b/crates/openfang-skills/src/clawhub.rs
@@ -593,14 +593,25 @@ impl ClawHubClient {
}
}
-/// Minimal URL-encoding for query parameters.
+/// RFC 3986 percent-encoding for query parameters.
+/// Unreserved characters pass through, space becomes `+`, everything else is `%XX`.
fn urlencoded(s: &str) -> String {
- s.replace(' ', "+")
- .replace('&', "%26")
- .replace('=', "%3D")
- .replace('?', "%3F")
- .replace('#', "%23")
- .replace('/', "%2F")
+ const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
+ let mut result = String::with_capacity(s.len() * 3);
+ for b in s.bytes() {
+ match b {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+ result.push(b as char);
+ }
+ b' ' => result.push('+'),
+ _ => {
+ result.push('%');
+ result.push(HEX_UPPER[(b >> 4) as usize] as char);
+ result.push(HEX_UPPER[(b & 0xf) as usize] as char);
+ }
+ }
+ }
+ result
}
/// Check if a binary is available on PATH.
@@ -786,6 +797,11 @@ mod tests {
assert_eq!(urlencoded("hello world"), "hello+world");
assert_eq!(urlencoded("a&b=c"), "a%26b%3Dc");
assert_eq!(urlencoded("path/to#frag"), "path%2Fto%23frag");
+ // Previously missed characters
+ assert_eq!(urlencoded("100%"), "100%25");
+ assert_eq!(urlencoded("a+b"), "a%2Bb");
+ // Unreserved chars pass through
+ assert_eq!(urlencoded("hello-world_2.0~test"), "hello-world_2.0~test");
}
#[test]
diff --git a/crates/openfang-types/src/agent.rs b/crates/openfang-types/src/agent.rs
index 600fcead..12f63f16 100644
--- a/crates/openfang-types/src/agent.rs
+++ b/crates/openfang-types/src/agent.rs
@@ -474,7 +474,8 @@ pub struct AgentManifest {
#[serde(default = "default_true")]
pub generate_identity_files: bool,
/// Per-agent exec policy override. If None, uses global exec_policy.
- #[serde(default)]
+ /// Accepts string shorthand ("allow", "deny", "full", "allowlist") or full table.
+ #[serde(default, deserialize_with = "crate::serde_compat::exec_policy_lenient")]
pub exec_policy: Option,
/// Tool allowlist — only these tools are available (empty = all tools).
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
diff --git a/crates/openfang-types/src/model_catalog.rs b/crates/openfang-types/src/model_catalog.rs
index 3e98e76b..92019518 100644
--- a/crates/openfang-types/src/model_catalog.rs
+++ b/crates/openfang-types/src/model_catalog.rs
@@ -20,6 +20,7 @@ pub const FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
pub const OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
pub const VLLM_BASE_URL: &str = "http://localhost:8000/v1";
pub const LMSTUDIO_BASE_URL: &str = "http://localhost:1234/v1";
+pub const LEMONADE_BASE_URL: &str = "http://localhost:8888/api/v1";
pub const PERPLEXITY_BASE_URL: &str = "https://api.perplexity.ai";
pub const COHERE_BASE_URL: &str = "https://api.cohere.com/v2";
pub const AI21_BASE_URL: &str = "https://api.ai21.com/studio/v1";
diff --git a/crates/openfang-types/src/serde_compat.rs b/crates/openfang-types/src/serde_compat.rs
index e1a50a03..b3b2fb77 100644
--- a/crates/openfang-types/src/serde_compat.rs
+++ b/crates/openfang-types/src/serde_compat.rs
@@ -157,6 +157,70 @@ where
deserializer.deserialize_any(MapLenientVisitor(PhantomData))
}
+/// Deserialize an `Option` leniently: accepts either a string
+/// shorthand (e.g., `"allow"`, `"deny"`, `"full"`, `"allowlist"`) which maps
+/// to `ExecPolicy { mode: , ..Default::default() }`, or the full
+/// struct/table form. Returns `None` for null/missing.
+pub fn exec_policy_lenient<'de, D>(
+ deserializer: D,
+) -> Result