community batch

This commit is contained in:
jaberjaber23
2026-03-07 04:22:16 +03:00
parent 4a3d570155
commit d237ecf161
16 changed files with 466 additions and 45 deletions
Generated
+14 -14
View File
@@ -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"
+4
View File
@@ -196,6 +196,10 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
"cache-control",
"no-store, no-cache, must-revalidate".parse().unwrap(),
);
headers.insert(
"strict-transport-security",
"max-age=63072000; includeSubDomains".parse().unwrap(),
);
response
}
+90
View File
@@ -5030,6 +5030,90 @@ pub async fn update_agent(
)
}
/// PATCH /api/agents/{id} — Partial update of agent fields (name, description, model, system_prompt).
pub async fn patch_agent(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> 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,
+1 -1
View File
@@ -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",
+40 -2
View File
@@ -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;
@@ -769,6 +769,12 @@
<div class="model-switcher-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
<option value="">All</option>
<template x-for="pn in switcherProviders" :key="pn">
<option :value="pn" x-text="pn"></option>
</template>
</select>
</div>
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
<div class="tool-card-spinner"></div>
@@ -841,6 +847,9 @@
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
</div>
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</template>
</div>
+19 -5
View File
@@ -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() {
+10 -4
View File
@@ -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<dyn LlmDriver>, 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<dyn LlmDriver> = 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
+7 -1
View File
@@ -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<ProviderDefaults> {
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",
+43 -5
View File
@@ -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<ProviderInfo> {
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<String, String> {
("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<ModelCatalogEntry> {
aliases: vec!["codegeex".into()],
},
// ══════════════════════════════════════════════════════════════
// Moonshot / Kimi (3)
// Moonshot / Kimi (5)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "moonshot-v1-128k".into(),
@@ -2857,7 +2867,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
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<ModelCatalogEntry> {
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]
@@ -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<ToolDefinition> {
"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<String, String> {
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"));
+23 -7
View File
@@ -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]
+2 -1
View File
@@ -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<crate::config::ExecPolicy>,
/// Tool allowlist — only these tools are available (empty = all tools).
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
@@ -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";
+143
View File
@@ -157,6 +157,70 @@ where
deserializer.deserialize_any(MapLenientVisitor(PhantomData))
}
/// Deserialize an `Option<ExecPolicy>` leniently: accepts either a string
/// shorthand (e.g., `"allow"`, `"deny"`, `"full"`, `"allowlist"`) which maps
/// to `ExecPolicy { mode: <parsed>, ..Default::default() }`, or the full
/// struct/table form. Returns `None` for null/missing.
pub fn exec_policy_lenient<'de, D>(
deserializer: D,
) -> Result<Option<crate::config::ExecPolicy>, D::Error>
where
D: Deserializer<'de>,
{
struct ExecPolicyVisitor;
impl<'de> Visitor<'de> for ExecPolicyVisitor {
type Value = Option<crate::config::ExecPolicy>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str(
"a string shorthand (\"allow\", \"deny\", \"full\", \"allowlist\") or an ExecPolicy table",
)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
let mode = match v.to_lowercase().as_str() {
"deny" | "none" | "disabled" => crate::config::ExecSecurityMode::Deny,
"allowlist" | "restricted" => crate::config::ExecSecurityMode::Allowlist,
"full" | "allow" | "all" | "unrestricted" => crate::config::ExecSecurityMode::Full,
other => {
return Err(de::Error::unknown_variant(
other,
&[
"deny", "none", "disabled", "allowlist", "restricted", "full",
"allow", "all", "unrestricted",
],
));
}
};
Ok(Some(crate::config::ExecPolicy {
mode,
..Default::default()
}))
}
fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let policy = crate::config::ExecPolicy::deserialize(
de::value::MapAccessDeserializer::new(map),
)?;
Ok(Some(policy))
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
}
deserializer.deserialize_any(ExecPolicyVisitor)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -303,4 +367,83 @@ mod tests {
assert!(new.fallback_models.is_empty());
assert!(new.skills.is_empty());
}
// --- exec_policy_lenient tests ---
#[derive(Debug, Deserialize)]
struct TestExecPolicy {
#[serde(default, deserialize_with = "exec_policy_lenient")]
exec_policy: Option<crate::config::ExecPolicy>,
}
#[test]
fn exec_policy_string_allow() {
let toml_str = r#"exec_policy = "allow""#;
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
let policy = parsed.exec_policy.unwrap();
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full);
// Should have default safe_bins, timeout, etc.
assert!(!policy.safe_bins.is_empty());
assert_eq!(policy.timeout_secs, 30);
}
#[test]
fn exec_policy_string_deny() {
let toml_str = r#"exec_policy = "deny""#;
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
let policy = parsed.exec_policy.unwrap();
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Deny);
}
#[test]
fn exec_policy_string_full() {
let toml_str = r#"exec_policy = "full""#;
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
let policy = parsed.exec_policy.unwrap();
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full);
}
#[test]
fn exec_policy_string_allowlist() {
let toml_str = r#"exec_policy = "allowlist""#;
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
let policy = parsed.exec_policy.unwrap();
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Allowlist);
}
#[test]
fn exec_policy_table_form() {
let toml_str = r#"
[exec_policy]
mode = "full"
timeout_secs = 60
"#;
let parsed: TestExecPolicy = toml::from_str(toml_str).unwrap();
let policy = parsed.exec_policy.unwrap();
assert_eq!(policy.mode, crate::config::ExecSecurityMode::Full);
assert_eq!(policy.timeout_secs, 60);
}
#[test]
fn exec_policy_missing_is_none() {
let toml_str = r#"other_field = true"#;
// Use a struct with an extra ignored field
#[derive(Debug, Deserialize)]
struct Wrapper {
#[serde(default, deserialize_with = "exec_policy_lenient")]
exec_policy: Option<crate::config::ExecPolicy>,
#[allow(dead_code)]
#[serde(default)]
other_field: bool,
}
let parsed: Wrapper = toml::from_str(toml_str).unwrap();
assert!(parsed.exec_policy.is_none());
}
#[test]
fn exec_policy_string_invalid_errors() {
let toml_str = r#"exec_policy = "banana""#;
let result = toml::from_str::<TestExecPolicy>(toml_str);
assert!(result.is_err());
}
}
+25 -5
View File
@@ -115,19 +115,39 @@ install() {
codesign --force --sign - "$INSTALL_DIR/openfang" 2>/dev/null || true
fi
# Add to PATH
# Add to PATH — detect the user's login shell
USER_SHELL="${SHELL:-}"
# Fallback: check /etc/passwd if $SHELL is unset (e.g. minimal containers)
if [ -z "$USER_SHELL" ] && command -v getent &>/dev/null; then
USER_SHELL=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)
fi
if [ -z "$USER_SHELL" ] && [ -f /etc/passwd ]; then
USER_SHELL=$(grep "^$(id -un):" /etc/passwd 2>/dev/null | cut -d: -f7)
fi
SHELL_RC=""
case "${SHELL:-}" in
*/zsh) SHELL_RC="$HOME/.zshrc" ;;
case "$USER_SHELL" in
*/zsh) SHELL_RC="$HOME/.zshrc" ;;
*/bash) SHELL_RC="$HOME/.bashrc" ;;
*/fish) SHELL_RC="$HOME/.config/fish/config.fish" ;;
esac
# Also check for config files if shell detection failed
if [ -z "$SHELL_RC" ]; then
if [ -f "$HOME/.config/fish/config.fish" ]; then
SHELL_RC="$HOME/.config/fish/config.fish"
USER_SHELL="/usr/bin/fish"
elif [ -f "$HOME/.zshrc" ]; then
SHELL_RC="$HOME/.zshrc"
elif [ -f "$HOME/.bashrc" ]; then
SHELL_RC="$HOME/.bashrc"
fi
fi
if [ -n "$SHELL_RC" ] && ! grep -q "openfang" "$SHELL_RC" 2>/dev/null; then
case "${SHELL:-}" in
case "$USER_SHELL" in
*/fish)
mkdir -p "$(dirname "$SHELL_RC")"
echo "set -gx PATH \"$INSTALL_DIR\" \$PATH" >> "$SHELL_RC"
echo "fish_add_path \"$INSTALL_DIR\"" >> "$SHELL_RC"
;;
*)
echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC"