Compare commits

...
6 Commits
Author SHA1 Message Date
jaberjaber23 74ac992420 issue fixes 2026-03-04 03:34:12 +03:00
jaberjaber23 53e1b31777 version bump 2026-03-04 02:08:52 +03:00
jaberjaber23 603a94e560 issue fixes 2026-03-04 02:08:32 +03:00
jaberjaber23 fac4ad33e5 discord bugfixes 2026-03-04 01:17:37 +03:00
jaberjaber23 fe96cd1004 bugfixes batch 2026-03-03 21:26:06 +03:00
jaberjaber23 7c85308cf6 bugfixes batch 2026-03-03 20:28:46 +03:00
30 changed files with 855 additions and 292 deletions
Generated
+14 -14
View File
@@ -3866,7 +3866,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"async-trait",
"axum",
@@ -3902,7 +3902,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"async-trait",
"axum",
@@ -3933,7 +3933,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"clap",
"clap_complete",
@@ -3960,7 +3960,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"axum",
"open",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"aes-gcm",
"argon2",
@@ -4014,7 +4014,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"chrono",
"dashmap",
@@ -4031,7 +4031,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"async-trait",
"chrono",
@@ -4067,7 +4067,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"async-trait",
"chrono",
@@ -4086,7 +4086,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4105,7 +4105,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"anyhow",
"async-trait",
@@ -4137,7 +4137,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"chrono",
"hex",
@@ -4160,7 +4160,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"async-trait",
"chrono",
@@ -4179,7 +4179,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.3.4"
version = "0.3.8"
dependencies = [
"async-trait",
"chrono",
@@ -8791,7 +8791,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.3.4"
version = "0.3.8"
[[package]]
name = "yoke"
+1 -1
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.5"
version = "0.3.10"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
+13 -1
View File
@@ -79,8 +79,20 @@ pub async fn auth(
return next.run(request).await;
}
// Public endpoints that don't require auth (dashboard needs these)
// Shutdown is loopback-only (CLI on same machine) — skip token auth
let path = request.uri().path();
if path == "/api/shutdown" {
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(true); // default true for unix sockets / tests
if is_loopback {
return next.run(request).await;
}
}
// Public endpoints that don't require auth (dashboard needs these)
if path == "/"
|| path == "/logo.png"
|| path == "/favicon.ico"
+119 -53
View File
@@ -115,20 +115,63 @@ pub async fn spawn_agent(
/// GET /api/agents — List all agents.
pub async fn list_agents(State(state): State<Arc<AppState>>) -> impl IntoResponse {
// Snapshot catalog once for enrichment
let catalog = state.kernel.model_catalog.read().ok();
let dm = &state.kernel.config.default_model;
let agents: Vec<serde_json::Value> = state
.kernel
.registry
.list()
.into_iter()
.map(|e| {
// Resolve "default" provider/model to actual kernel defaults
let provider = if e.manifest.model.provider.is_empty()
|| e.manifest.model.provider == "default"
{
dm.provider.as_str()
} else {
e.manifest.model.provider.as_str()
};
let model = if e.manifest.model.model.is_empty()
|| e.manifest.model.model == "default"
{
dm.model.as_str()
} else {
e.manifest.model.model.as_str()
};
// Enrich from catalog
let (tier, auth_status) = catalog
.as_ref()
.map(|cat| {
let tier = cat
.find_model(model)
.map(|m| format!("{:?}", m.tier).to_lowercase())
.unwrap_or_else(|| "unknown".to_string());
let auth = cat
.get_provider(provider)
.map(|p| format!("{:?}", p.auth_status).to_lowercase())
.unwrap_or_else(|| "unknown".to_string());
(tier, auth)
})
.unwrap_or(("unknown".to_string(), "unknown".to_string()));
let ready = matches!(e.state, openfang_types::agent::AgentState::Running)
&& auth_status != "missing";
serde_json::json!({
"id": e.id.to_string(),
"name": e.name,
"state": format!("{:?}", e.state),
"mode": e.mode,
"created_at": e.created_at.to_rfc3339(),
"model_provider": e.manifest.model.provider,
"model_name": e.manifest.model.model,
"last_active": e.last_active.to_rfc3339(),
"model_provider": provider,
"model_name": model,
"model_tier": tier,
"auth_status": auth_status,
"ready": ready,
"profile": e.manifest.profile,
"identity": {
"emoji": e.identity.emoji,
@@ -1959,7 +2002,7 @@ pub async fn configure_channel(
let home = openfang_kernel::config::openfang_home();
let secrets_path = home.join("secrets.env");
let config_path = home.join("config.toml");
let mut config_fields: HashMap<String, String> = HashMap::new();
let mut config_fields: HashMap<String, (String, FieldType)> = HashMap::new();
for field_def in meta.fields {
let value = fields
@@ -1982,9 +2025,15 @@ pub async fn configure_channel(
unsafe {
std::env::set_var(env_var, value);
}
// Also write the env var NAME to config.toml so the channel section
// is not empty and the kernel knows which env var to read.
config_fields.insert(
field_def.key.to_string(),
(env_var.to_string(), FieldType::Text),
);
} else {
// Config field — collect for TOML write
config_fields.insert(field_def.key.to_string(), value.to_string());
// Config field — collect for TOML write with type info
config_fields.insert(field_def.key.to_string(), (value.to_string(), field_def.field_type));
}
}
@@ -2455,19 +2504,14 @@ pub async fn get_template(Path(name): Path<String>) -> impl IntoResponse {
// ---------------------------------------------------------------------------
/// GET /api/memory/agents/:id/kv — List KV pairs for an agent.
///
/// Note: memory_store tool writes to a shared namespace, so we read from that
/// same namespace regardless of which agent ID is in the URL.
pub async fn get_agent_kv(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Path(_id): Path<String>,
) -> 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"})),
);
}
};
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
match state.kernel.memory.list_kv(agent_id) {
Ok(pairs) => {
@@ -2478,7 +2522,7 @@ pub async fn get_agent_kv(
(StatusCode::OK, Json(serde_json::json!({"kv_pairs": kv})))
}
Err(e) => {
tracing::warn!("Memory list_kv failed for agent {id}: {e}");
tracing::warn!("Memory list_kv failed: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Memory operation failed"})),
@@ -2490,17 +2534,9 @@ pub async fn get_agent_kv(
/// GET /api/memory/agents/:id/kv/:key — Get a specific KV value.
pub async fn get_agent_kv_key(
State(state): State<Arc<AppState>>,
Path((id, key)): Path<(String, String)>,
Path((_id, key)): Path<(String, String)>,
) -> 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"})),
);
}
};
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
match state.kernel.memory.structured_get(agent_id, &key) {
Ok(Some(val)) => (
@@ -2512,7 +2548,7 @@ pub async fn get_agent_kv_key(
Json(serde_json::json!({"error": "Key not found"})),
),
Err(e) => {
tracing::warn!("Memory get failed for agent {id}, key '{key}': {e}");
tracing::warn!("Memory get failed for key '{key}': {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Memory operation failed"})),
@@ -2524,18 +2560,10 @@ pub async fn get_agent_kv_key(
/// PUT /api/memory/agents/:id/kv/:key — Set a KV value.
pub async fn set_agent_kv_key(
State(state): State<Arc<AppState>>,
Path((id, key)): Path<(String, String)>,
Path((_id, key)): Path<(String, 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"})),
);
}
};
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
let value = body.get("value").cloned().unwrap_or(body);
@@ -2545,7 +2573,7 @@ pub async fn set_agent_kv_key(
Json(serde_json::json!({"status": "stored", "key": key})),
),
Err(e) => {
tracing::warn!("Memory set failed for agent {id}, key '{key}': {e}");
tracing::warn!("Memory set failed for key '{key}': {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Memory operation failed"})),
@@ -2557,17 +2585,9 @@ pub async fn set_agent_kv_key(
/// DELETE /api/memory/agents/:id/kv/:key — Delete a KV value.
pub async fn delete_agent_kv_key(
State(state): State<Arc<AppState>>,
Path((id, key)): Path<(String, String)>,
Path((_id, key)): Path<(String, String)>,
) -> 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"})),
);
}
};
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
match state.kernel.memory.structured_delete(agent_id, &key) {
Ok(()) => (
@@ -2575,7 +2595,7 @@ pub async fn delete_agent_kv_key(
Json(serde_json::json!({"status": "deleted", "key": key})),
),
Err(e) => {
tracing::warn!("Memory delete failed for agent {id}, key '{key}': {e}");
tracing::warn!("Memory delete failed for key '{key}': {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Memory operation failed"})),
@@ -3614,6 +3634,42 @@ pub async fn install_hand_deps(
)
}
/// POST /api/hands/install — Install a hand from TOML content.
pub async fn install_hand(
State(state): State<Arc<AppState>>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
let toml_content = body["toml_content"].as_str().unwrap_or("");
let skill_content = body["skill_content"].as_str().unwrap_or("");
if toml_content.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing toml_content field"})),
);
}
match state
.kernel
.hand_registry
.install_from_content(toml_content, skill_content)
{
Ok(def) => (
StatusCode::OK,
Json(serde_json::json!({
"id": def.id,
"name": def.name,
"description": def.description,
"category": format!("{:?}", def.category),
})),
),
Err(e) => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("{e}")})),
),
}
}
/// POST /api/hands/{hand_id}/activate — Activate a hand (spawns agent).
pub async fn activate_hand(
State(state): State<Arc<AppState>>,
@@ -6752,7 +6808,7 @@ fn remove_secret_env(path: &std::path::Path, key: &str) -> Result<(), std::io::E
fn upsert_channel_config(
config_path: &std::path::Path,
channel_name: &str,
fields: &HashMap<String, String>,
fields: &HashMap<String, (String, FieldType)>,
) -> Result<(), Box<dyn std::error::Error>> {
let content = if config_path.exists() {
std::fs::read_to_string(config_path)?
@@ -6780,10 +6836,20 @@ fn upsert_channel_config(
.and_then(|v| v.as_table_mut())
.ok_or("channels is not a table")?;
// Build channel sub-table
// Build channel sub-table with correct TOML types
let mut ch_table = toml::map::Map::new();
for (k, v) in fields {
ch_table.insert(k.clone(), toml::Value::String(v.clone()));
for (k, (v, ft)) in fields {
let toml_val = match ft {
FieldType::Number => {
if let Ok(n) = v.parse::<i64>() {
toml::Value::Integer(n)
} else {
toml::Value::String(v.clone())
}
}
_ => toml::Value::String(v.clone()),
};
ch_table.insert(k.clone(), toml_val);
}
channels_table.insert(channel_name.to_string(), toml::Value::Table(ch_table));
+4
View File
@@ -331,6 +331,10 @@ pub async fn build_router(
)
// Hands endpoints
.route("/api/hands", axum::routing::get(routes::list_hands))
.route(
"/api/hands/install",
axum::routing::post(routes::install_hand),
)
.route(
"/api/hands/active",
axum::routing::get(routes::list_active_hands),
+6 -1
View File
@@ -796,7 +796,12 @@ async fn handle_command(
} else {
match state.kernel.set_agent_model(agent_id, args) {
Ok(()) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
let msg = if let Some(entry) = state.kernel.registry.get(agent_id) {
format!("Model switched to: {} (provider: {})", entry.manifest.model.model, entry.manifest.model.provider)
} else {
format!("Model switched to: {args}")
};
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
}
Err(e) => {
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
@@ -589,6 +589,14 @@
<!-- Messages area -->
<div class="messages" id="messages" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="handleDrop($event); dragOver = false">
<!-- Empty state: no agent selected -->
<template x-if="!currentAgent">
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:32px;opacity:0.8">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px;opacity:0.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to start chatting</h3>
<p class="text-dim" style="font-size:13px;max-width:320px">Choose an agent from the sidebar or go to the Agents tab to create a new one.</p>
</div>
</template>
<!-- Message list -->
<template x-if="currentAgent">
<div>
@@ -3051,6 +3059,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
</div>
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F916;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
<button class="btn btn-ghost btn-sm mt-2" x-show="models.length && (modelSearch || modelProviderFilter)" @click="modelSearch=''; modelProviderFilter=''">Clear Filters</button>
</div>
<div class="table-wrap" x-show="filteredModels.length">
<table>
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th></tr></thead>
@@ -3078,6 +3092,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<input placeholder="Search tools..." x-model="toolSearch">
</div>
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
<div x-show="!filteredTools.length && !settingsLoading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F527;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
<button class="btn btn-ghost btn-sm mt-2" x-show="tools.length && toolSearch" @click="toolSearch=''">Clear Search</button>
</div>
<div class="table-wrap" x-show="filteredTools.length">
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
@@ -441,8 +441,8 @@ function wizardPage() {
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'name = "' + model + '"\n\n';
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
this.creatingAgent = true;
try {
@@ -222,10 +222,10 @@ async fn test_status_endpoint() {
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "running");
assert_eq!(body["agent_count"], 0);
assert_eq!(body["agent_count"], 1); // default assistant auto-spawned
assert!(body["uptime_seconds"].is_number());
assert_eq!(body["default_provider"], "ollama");
assert_eq!(body["agents"].as_array().unwrap().len(), 0);
assert_eq!(body["agents"].as_array().unwrap().len(), 1);
}
#[tokio::test]
@@ -247,7 +247,7 @@ async fn test_spawn_list_kill_agent() {
let agent_id = body["agent_id"].as_str().unwrap().to_string();
assert!(!agent_id.is_empty());
// --- List (1 agent) ---
// --- List (2 agents: default assistant + test-agent) ---
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
@@ -255,10 +255,10 @@ async fn test_spawn_list_kill_agent() {
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["name"], "test-agent");
assert_eq!(agents[0]["id"], agent_id);
assert_eq!(agents[0]["model_provider"], "ollama");
assert_eq!(agents.len(), 2);
let test_agent = agents.iter().find(|a| a["name"] == "test-agent").unwrap();
assert_eq!(test_agent["id"], agent_id);
assert_eq!(test_agent["model_provider"], "ollama");
// --- Kill ---
let resp = client
@@ -270,7 +270,7 @@ async fn test_spawn_list_kill_agent() {
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "killed");
// --- List (empty) ---
// --- List (only default assistant remains) ---
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
@@ -278,7 +278,8 @@ async fn test_spawn_list_kill_agent() {
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 0);
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["name"], "assistant");
}
#[tokio::test]
@@ -617,14 +618,14 @@ memory_write = ["self.*"]
ids.push(body["agent_id"].as_str().unwrap().to_string());
}
// List should show 3
// List should show 4 (3 spawned + default assistant)
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 3);
assert_eq!(agents.len(), 4);
// Status should agree
let resp = client
@@ -633,7 +634,7 @@ memory_write = ["self.*"]
.await
.unwrap();
let status: serde_json::Value = resp.json().await.unwrap();
assert_eq!(status["agent_count"], 3);
assert_eq!(status["agent_count"], 4);
// Kill one
let resp = client
@@ -643,14 +644,14 @@ memory_write = ["self.*"]
.unwrap();
assert_eq!(resp.status(), 200);
// List should show 2
// List should show 3 (2 spawned + default assistant)
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 2);
assert_eq!(agents.len(), 3);
// Kill the rest
for id in [&ids[0], &ids[2]] {
@@ -661,14 +662,14 @@ memory_write = ["self.*"]
.unwrap();
}
// List should be empty
// List should have only default assistant
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 0);
assert_eq!(agents.len(), 1);
}
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -541,7 +541,7 @@ async fn load_spawn_kill_cycle() {
.await
.unwrap();
let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(remaining, 0, "All agents should be killed");
assert_eq!(remaining, 1, "Only default assistant should remain");
}
/// Test: Prometheus metrics endpoint under sustained load.
+27 -8
View File
@@ -582,14 +582,33 @@ async fn dispatch_message(
let agent_id = match agent_id {
Some(id) => id,
None => {
send_response(
adapter,
&message.sender,
"No agent assigned. Use /agents to list available agents, then /agent <name> to select one.".to_string(),
thread_id,
output_format,
).await;
return;
// Fallback: try "assistant" agent, then first available agent
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
let fallback = match fallback {
Some(id) => Some(id),
None => handle
.list_agents()
.await
.ok()
.and_then(|agents| agents.first().map(|(id, _)| *id)),
};
match fallback {
Some(id) => {
// Auto-set this as the user's default so future messages route directly
router.set_user_default(message.sender.platform_id.clone(), id);
id
}
None => {
send_response(
adapter,
&message.sender,
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
thread_id,
output_format,
).await;
return;
}
}
}
};
+63 -1
View File
@@ -79,8 +79,13 @@ impl TelegramAdapter {
self.token.as_str()
);
// Sanitize: strip unsupported HTML tags so Telegram doesn't reject with 400.
// Telegram only allows: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
// Any other tag (e.g. <name>, <thinking>) causes a 400 Bad Request.
let sanitized = sanitize_telegram_html(text);
// Telegram has a 4096 character limit per message — split if needed
let chunks = split_message(text, 4096);
let chunks = split_message(&sanitized, 4096);
for chunk in chunks {
let body = serde_json::json!({
"chat_id": chat_id,
@@ -524,6 +529,63 @@ pub fn calculate_backoff(current: Duration) -> Duration {
(current * 2).min(MAX_BACKOFF)
}
/// Sanitize text for Telegram HTML parse mode.
///
/// Escapes angle brackets that are NOT part of Telegram-allowed HTML tags.
/// Allowed tags: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
/// Everything else (e.g. `<name>`, `<thinking>`) gets escaped to `&lt;...&gt;`.
fn sanitize_telegram_html(text: &str) -> String {
const ALLOWED: &[&str] = &[
"b", "i", "u", "s", "em", "strong", "a", "code", "pre", "blockquote", "tg-spoiler",
"tg-emoji",
];
let mut result = String::with_capacity(text.len());
let mut chars = text.char_indices().peekable();
while let Some(&(i, ch)) = chars.peek() {
if ch == '<' {
// Try to parse an HTML tag
if let Some(end_offset) = text[i..].find('>') {
let tag_end = i + end_offset;
let tag_content = &text[i + 1..tag_end]; // content between < and >
let tag_name = tag_content
.trim_start_matches('/')
.split(|c: char| c.is_whitespace() || c == '/' || c == '>')
.next()
.unwrap_or("")
.to_lowercase();
if !tag_name.is_empty() && ALLOWED.contains(&tag_name.as_str()) {
// Allowed tag — keep as-is
result.push_str(&text[i..tag_end + 1]);
} else {
// Unknown tag — escape both brackets
result.push_str("&lt;");
result.push_str(tag_content);
result.push_str("&gt;");
}
// Advance past the whole tag
while let Some(&(j, _)) = chars.peek() {
chars.next();
if j >= tag_end {
break;
}
}
} else {
// No closing > — escape the lone <
result.push_str("&lt;");
chars.next();
}
} else {
result.push(ch);
chars.next();
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@@ -379,8 +379,8 @@ async fn test_bridge_dispatch_no_agent_assigned() {
let sent = adapter_ref.get_sent();
assert_eq!(sent.len(), 1);
assert!(
sent[0].1.contains("No agent assigned"),
"Expected 'No agent assigned' message, got: {}",
sent[0].1.contains("No agents available"),
"Expected 'No agents available' message, got: {}",
sent[0].1
);
+10 -2
View File
@@ -6,9 +6,17 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
fn dotenv_openfang_home() -> Option<PathBuf> {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return Some(PathBuf::from(home));
}
dirs::home_dir().map(|h| h.join(".openfang"))
}
/// Return the path to `~/.openfang/.env`.
pub fn env_file_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
dotenv_openfang_home().map(|h| h.join(".env"))
}
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
@@ -25,7 +33,7 @@ pub fn load_dotenv() {
/// Return the path to `~/.openfang/secrets.env`.
pub fn secrets_env_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env"))
dotenv_openfang_home().map(|h| h.join("secrets.env"))
}
fn load_env_file(path: Option<PathBuf>) {
+8 -4
View File
@@ -39,11 +39,15 @@ fn detect_provider() -> Option<(&'static str, &'static str)> {
}
fn is_first_run() -> bool {
let home = match dirs::home_dir() {
Some(h) => h,
None => return true,
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => return true,
}
};
!home.join(".openfang").join("config.toml").exists()
!of_home.join("config.toml").exists()
}
fn has_openclaw() -> bool {
+121 -58
View File
@@ -390,6 +390,11 @@ enum HandCommands {
List,
/// Show currently active hand instances.
Active,
/// Install a hand from a local directory containing HAND.toml.
Install {
/// Path to the hand directory (must contain HAND.toml).
path: String,
},
/// Activate a hand by ID.
Activate {
/// Hand ID (e.g. "clip", "lead", "researcher").
@@ -781,11 +786,19 @@ fn init_tracing_stderr() {
.init();
}
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
fn cli_openfang_home() -> std::path::PathBuf {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return std::path::PathBuf::from(home);
}
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
}
/// Redirect tracing to a log file so it doesn't corrupt the ratatui TUI.
fn init_tracing_file() {
let log_dir = dirs::home_dir()
.map(|h| h.join(".openfang"))
.unwrap_or_else(|| std::path::PathBuf::from("."));
let log_dir = cli_openfang_home();
let _ = std::fs::create_dir_all(&log_dir);
let log_path = log_dir.join("tui.log");
@@ -910,6 +923,7 @@ fn main() {
Some(Commands::Hand(sub)) => match sub {
HandCommands::List => cmd_hand_list(),
HandCommands::Active => cmd_hand_active(),
HandCommands::Install { path } => cmd_hand_install(&path),
HandCommands::Activate { id } => cmd_hand_activate(&id),
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
HandCommands::Info { id } => cmd_hand_info(&id),
@@ -1037,7 +1051,7 @@ pub(crate) fn restrict_dir_permissions(path: &std::path::Path) {
pub(crate) fn restrict_dir_permissions(_path: &std::path::Path) {}
pub(crate) fn find_daemon() -> Option<String> {
let home_dir = dirs::home_dir()?.join(".openfang");
let home_dir = cli_openfang_home();
let info = read_daemon_info(&home_dir)?;
// Normalize listen address: replace 0.0.0.0 with 127.0.0.1 to avoid
@@ -1119,7 +1133,7 @@ fn cmd_init(quick: bool) {
}
};
let openfang_dir = home.join(".openfang");
let openfang_dir = cli_openfang_home();
// --- Ensure directories exist ---
if !openfang_dir.exists() {
@@ -1421,7 +1435,7 @@ fn cmd_start(config: Option<PathBuf>) {
/// Read the api_key from ~/.openfang/config.toml (if any).
fn read_api_key() -> Option<String> {
let config_path = dirs::home_dir()?.join(".openfang").join("config.toml");
let config_path = cli_openfang_home().join("config.toml");
let text = std::fs::read_to_string(config_path).ok()?;
let table: toml::Value = text.parse().ok()?;
let key = table.get("api_key")?.as_str()?;
@@ -1451,8 +1465,8 @@ fn cmd_stop() {
}
}
// Still alive — force kill via PID
if let Some(home) = dirs::home_dir() {
let of_dir = home.join(".openfang");
{
let of_dir = cli_openfang_home();
if let Some(info) = read_daemon_info(&of_dir) {
force_kill_pid(info.pid);
let _ = std::fs::remove_file(of_dir.join("daemon.json"));
@@ -1927,8 +1941,8 @@ fn cmd_doctor(json: bool, repair: bool) {
}
let home = dirs::home_dir();
if let Some(h) = &home {
let openfang_dir = h.join(".openfang");
if let Some(_h) = &home {
let openfang_dir = cli_openfang_home();
// --- Check 1: OpenFang directory ---
if openfang_dir.exists() {
@@ -2085,7 +2099,20 @@ decay_rate = 0.05
all_ok = false;
}
// --- Check 4: Port 4200 availability ---
// --- Check 4: Port availability ---
// Read api_listen from config (default: 127.0.0.1:4200)
let api_listen = {
let cfg_path = openfang_dir.join("config.toml");
if cfg_path.exists() {
std::fs::read_to_string(&cfg_path)
.ok()
.and_then(|s| toml::from_str::<openfang_types::config::KernelConfig>(&s).ok())
.map(|c| c.api_listen)
.unwrap_or_else(|| "127.0.0.1:4200".to_string())
} else {
"127.0.0.1:4200".to_string()
}
};
if !json {
println!();
}
@@ -2101,19 +2128,24 @@ decay_rate = 0.05
}
checks.push(serde_json::json!({"check": "daemon", "status": "warn"}));
// Check if port 4200 is available
match std::net::TcpListener::bind("127.0.0.1:4200") {
// Check if the configured port is available
let bind_addr = if api_listen.starts_with("0.0.0.0") {
api_listen.replacen("0.0.0.0", "127.0.0.1", 1)
} else {
api_listen.clone()
};
match std::net::TcpListener::bind(&bind_addr) {
Ok(_) => {
if !json {
ui::check_ok("Port 4200 is available");
ui::check_ok(&format!("Port {api_listen} is available"));
}
checks.push(serde_json::json!({"check": "port_4200", "status": "ok"}));
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
}
Err(_) => {
if !json {
ui::check_warn("Port 4200 is in use by another process");
ui::check_warn(&format!("Port {api_listen} is in use by another process"));
}
checks.push(serde_json::json!({"check": "port_4200", "status": "warn"}));
checks.push(serde_json::json!({"check": "port", "status": "warn", "address": api_listen}));
}
}
}
@@ -2333,8 +2365,8 @@ decay_rate = 0.05
}
// --- Check 11: .env keys vs config api_key_env consistency ---
if let Some(ref h) = home {
let openfang_dir = h.join(".openfang");
{
let openfang_dir = cli_openfang_home();
let config_path = openfang_dir.join("config.toml");
if config_path.exists() {
let config_str = std::fs::read_to_string(&config_path).unwrap_or_default();
@@ -2359,8 +2391,8 @@ decay_rate = 0.05
}
// --- Check 12: Config deserialization into KernelConfig ---
if let Some(ref h) = home {
let openfang_dir = h.join(".openfang");
{
let openfang_dir = cli_openfang_home();
let config_path = openfang_dir.join("config.toml");
if config_path.exists() {
if !json {
@@ -2464,10 +2496,7 @@ decay_rate = 0.05
if !json {
println!("\n Skills:");
}
let skills_dir = home
.as_ref()
.map(|h| h.join(".openfang").join("skills"))
.unwrap_or_else(|| std::path::PathBuf::from("skills"));
let skills_dir = cli_openfang_home().join("skills");
let mut skill_reg = openfang_skills::registry::SkillRegistry::new(skills_dir.clone());
skill_reg.load_bundled();
let bundled_count = skill_reg.count();
@@ -2528,11 +2557,11 @@ decay_rate = 0.05
}
// --- Check 14: Extension registry health ---
if let Some(ref h) = home {
{
if !json {
println!("\n Extensions:");
}
let openfang_dir = h.join(".openfang");
let openfang_dir = cli_openfang_home();
let mut ext_registry =
openfang_extensions::registry::IntegrationRegistry::new(&openfang_dir);
ext_registry.load_bundled();
@@ -3143,12 +3172,7 @@ fn cmd_migrate(args: MigrateArgs) {
}
});
let target_dir = dirs::home_dir()
.unwrap_or_else(|| {
eprintln!("Error: Could not determine home directory");
std::process::exit(1);
})
.join(".openfang");
let target_dir = cli_openfang_home();
println!("Migrating from {} ({})...", source, source_dir.display());
if args.dry_run {
@@ -3839,6 +3863,52 @@ fn cmd_channel_toggle(channel: &str, enable: bool) {
// Hand commands
// ---------------------------------------------------------------------------
fn cmd_hand_install(path: &str) {
let base = require_daemon("hand install");
let dir = std::path::Path::new(path);
let toml_path = dir.join("HAND.toml");
let skill_path = dir.join("SKILL.md");
if !toml_path.exists() {
eprintln!(
"Error: No HAND.toml found in {}",
dir.canonicalize()
.unwrap_or_else(|_| dir.to_path_buf())
.display()
);
std::process::exit(1);
}
let toml_content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| {
eprintln!("Error reading {}: {e}", toml_path.display());
std::process::exit(1);
});
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
let client = daemon_client();
let body = daemon_json(
client
.post(format!("{base}/api/hands/install"))
.json(&serde_json::json!({
"toml_content": toml_content,
"skill_content": skill_content,
}))
.send(),
);
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
eprintln!("Error: {err}");
std::process::exit(1);
}
println!(
"Installed hand: {} ({})",
body["name"].as_str().unwrap_or("?"),
body["id"].as_str().unwrap_or("?"),
);
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
}
fn cmd_hand_list() {
let base = require_daemon("hand list");
let client = daemon_client();
@@ -4565,6 +4635,9 @@ fn cmd_quick_chat(config: Option<PathBuf>, agent: Option<String>) {
// ---------------------------------------------------------------------------
pub(crate) fn openfang_home() -> PathBuf {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return PathBuf::from(home);
}
dirs::home_dir()
.unwrap_or_else(|| {
eprintln!("Error: Could not determine home directory");
@@ -5407,9 +5480,7 @@ fn cmd_sessions(agent: Option<&str>, json: bool) {
}
fn cmd_logs(lines: usize, follow: bool) {
let log_path = dirs::home_dir()
.map(|h| h.join(".openfang").join("tui.log"))
.unwrap_or_else(|| PathBuf::from("tui.log"));
let log_path = cli_openfang_home().join("tui.log");
if !log_path.exists() {
ui::error_with_fix(
@@ -5939,13 +6010,7 @@ fn cmd_system_version(json: bool) {
}
fn cmd_reset(confirm: bool) {
let openfang_dir = match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => {
ui::error("Could not determine home directory");
std::process::exit(1);
}
};
let openfang_dir = cli_openfang_home();
if !openfang_dir.exists() {
println!(
@@ -5980,14 +6045,7 @@ fn cmd_reset(confirm: bool) {
// ---------------------------------------------------------------------------
fn cmd_uninstall(confirm: bool, keep_config: bool) {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
ui::error("Could not determine home directory");
std::process::exit(1);
}
};
let openfang_dir = home.join(".openfang");
let openfang_dir = cli_openfang_home();
let exe_path = std::env::current_exe().ok();
// Step 1: Show what will be removed
@@ -6013,11 +6071,15 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
println!(" • Remove binary: {}", exe.display());
}
// Check cargo bin path
let cargo_bin = home.join(".cargo").join("bin").join(if cfg!(windows) {
"openfang.exe"
} else {
"openfang"
});
let cargo_bin = dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".cargo")
.join("bin")
.join(if cfg!(windows) {
"openfang.exe"
} else {
"openfang"
});
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
println!(" • Remove cargo binary: {}", cargo_bin.display());
}
@@ -6051,12 +6113,13 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
}
// Step 4: Remove auto-start entries
remove_autostart_entries(&home);
let user_home = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
remove_autostart_entries(&user_home);
// Step 5: Clean PATH from shell configs
if let Some(ref exe) = exe_path {
if let Some(bin_dir) = exe.parent() {
clean_path_entries(&home, &bin_dir.to_string_lossy());
clean_path_entries(&user_home, &bin_dir.to_string_lossy());
}
}
+10 -3
View File
@@ -35,9 +35,16 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
}
}
// Installed templates
if let Some(home) = dirs::home_dir() {
let agents = home.join(".openfang").join("agents");
// Installed templates (respects OPENFANG_HOME)
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else if let Some(home) = dirs::home_dir() {
home.join(".openfang")
} else {
std::env::temp_dir().join(".openfang")
};
{
let agents = of_home.join("agents");
if agents.is_dir() && !dirs.contains(&agents) {
dirs.push(agents);
}
@@ -825,8 +825,11 @@ fn handle_migration_key(
if yes {
state.migration_phase = MigrationPhase::Running;
let source_dir = state.openclaw_path.clone().unwrap_or_default();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let target_dir = home.join(".openfang");
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
};
let tx = migrate_tx.clone();
std::thread::spawn(move || {
let options = openfang_migrate::MigrateOptions {
@@ -945,15 +948,17 @@ fn save_config(state: &mut State) {
}
};
let home = match dirs::home_dir() {
Some(h) => h,
None => {
state.save_error = "Could not determine home directory".to_string();
return;
let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => {
state.save_error = "Could not determine home directory".to_string();
return;
}
}
};
let openfang_dir = home.join(".openfang");
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
crate::restrict_dir_permissions(&openfang_dir);
+78 -12
View File
@@ -67,6 +67,66 @@ const PROVIDERS: &[ProviderInfo] = &[
default_model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
needs_key: true,
},
ProviderInfo {
name: "gemini",
env_var: "GEMINI_API_KEY",
default_model: "gemini-2.5-flash",
needs_key: true,
},
ProviderInfo {
name: "xai",
env_var: "XAI_API_KEY",
default_model: "grok-4-0709",
needs_key: true,
},
ProviderInfo {
name: "qwen",
env_var: "DASHSCOPE_API_KEY",
default_model: "qwen-plus",
needs_key: true,
},
ProviderInfo {
name: "perplexity",
env_var: "PERPLEXITY_API_KEY",
default_model: "sonar-pro",
needs_key: true,
},
ProviderInfo {
name: "cohere",
env_var: "CO_API_KEY",
default_model: "command-a",
needs_key: true,
},
ProviderInfo {
name: "cerebras",
env_var: "CEREBRAS_API_KEY",
default_model: "llama-3.3-70b",
needs_key: true,
},
ProviderInfo {
name: "sambanova",
env_var: "SAMBANOVA_API_KEY",
default_model: "Meta-Llama-3.3-70B-Instruct",
needs_key: true,
},
ProviderInfo {
name: "moonshot",
env_var: "MOONSHOT_API_KEY",
default_model: "moonshot-v1-128k",
needs_key: true,
},
ProviderInfo {
name: "zhipu",
env_var: "ZHIPU_API_KEY",
default_model: "glm-4-plus",
needs_key: true,
},
ProviderInfo {
name: "zhipu_coding",
env_var: "ZHIPU_API_KEY",
default_model: "codegeex-4",
needs_key: true,
},
ProviderInfo {
name: "ollama",
env_var: "OLLAMA_API_KEY",
@@ -89,11 +149,15 @@ const PROVIDERS: &[ProviderInfo] = &[
/// Check if first-run setup is needed.
pub fn needs_setup() -> bool {
let home = match dirs::home_dir() {
Some(h) => h,
None => return true,
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => return true,
}
};
!home.join(".openfang").join("config.toml").exists()
!of_home.join("config.toml").exists()
}
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -294,16 +358,18 @@ impl WizardState {
}
};
let home = match dirs::home_dir() {
Some(h) => h,
None => {
self.status_msg = "Could not determine home directory".to_string();
self.step = WizardStep::Done;
return;
let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => {
self.status_msg = "Could not determine home directory".to_string();
self.step = WizardStep::Done;
return;
}
}
};
let openfang_dir = home.join(".openfang");
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
crate::restrict_dir_permissions(&openfang_dir);
+58 -14
View File
@@ -38,7 +38,7 @@ pub struct SettingStatus {
/// The Hand registry — stores definitions and tracks active instances.
pub struct HandRegistry {
/// All known hand definitions, keyed by hand_id.
definitions: HashMap<String, HandDefinition>,
definitions: DashMap<String, HandDefinition>,
/// Active hand instances, keyed by instance UUID.
instances: DashMap<Uuid, HandInstance>,
}
@@ -47,13 +47,13 @@ impl HandRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
definitions: HashMap::new(),
definitions: DashMap::new(),
instances: DashMap::new(),
}
}
/// Load all bundled hand definitions. Returns count of definitions loaded.
pub fn load_bundled(&mut self) -> usize {
pub fn load_bundled(&self) -> usize {
let bundled = bundled::bundled_hands();
let mut count = 0;
for (id, toml_content, skill_content) in bundled {
@@ -71,16 +71,60 @@ impl HandRegistry {
count
}
/// Install a hand from a directory containing HAND.toml (and optional SKILL.md).
pub fn install_from_path(&self, path: &std::path::Path) -> HandResult<HandDefinition> {
let toml_path = path.join("HAND.toml");
let skill_path = path.join("SKILL.md");
let toml_content = std::fs::read_to_string(&toml_path).map_err(|e| {
HandError::NotFound(format!("Cannot read {}: {e}", toml_path.display()))
})?;
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
let def = bundled::parse_bundled("custom", &toml_content, &skill_content)?;
if self.definitions.contains_key(&def.id) {
return Err(HandError::AlreadyActive(format!(
"Hand '{}' already registered",
def.id
)));
}
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
/// Install a hand from raw TOML + skill content (for API-based installs).
pub fn install_from_content(
&self,
toml_content: &str,
skill_content: &str,
) -> HandResult<HandDefinition> {
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
if self.definitions.contains_key(&def.id) {
return Err(HandError::AlreadyActive(format!(
"Hand '{}' already registered",
def.id
)));
}
info!(hand = %def.id, name = %def.name, "Installed hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
/// List all known hand definitions.
pub fn list_definitions(&self) -> Vec<&HandDefinition> {
let mut defs: Vec<&HandDefinition> = self.definitions.values().collect();
defs.sort_by_key(|d| &d.name);
pub fn list_definitions(&self) -> Vec<HandDefinition> {
let mut defs: Vec<HandDefinition> = self.definitions.iter().map(|r| r.value().clone()).collect();
defs.sort_by(|a, b| a.name.cmp(&b.name));
defs
}
/// Get a specific hand definition by ID.
pub fn get_definition(&self, hand_id: &str) -> Option<&HandDefinition> {
self.definitions.get(hand_id)
pub fn get_definition(&self, hand_id: &str) -> Option<HandDefinition> {
self.definitions.get(hand_id).map(|r| r.value().clone())
}
/// Activate a hand — creates an instance (agent spawning is done by kernel).
@@ -344,7 +388,7 @@ mod tests {
#[test]
fn load_bundled_hands() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
let count = reg.load_bundled();
assert_eq!(count, 7);
assert!(!reg.list_definitions().is_empty());
@@ -368,7 +412,7 @@ mod tests {
#[test]
fn activate_and_deactivate() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
@@ -390,7 +434,7 @@ mod tests {
#[test]
fn pause_and_resume() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
@@ -409,7 +453,7 @@ mod tests {
#[test]
fn set_agent() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
@@ -427,7 +471,7 @@ mod tests {
#[test]
fn check_requirements() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let results = reg.check_requirements("clip").unwrap();
@@ -452,7 +496,7 @@ mod tests {
#[test]
fn set_error_status() {
let mut reg = HandRegistry::new();
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("clip", HashMap::new()).unwrap();
+9 -5
View File
@@ -224,15 +224,19 @@ pub fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
}
/// Get the default config file path.
///
/// Respects `OPENFANG_HOME` env var (e.g. `OPENFANG_HOME=/opt/openfang`).
pub fn default_config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
.join("config.toml")
openfang_home().join("config.toml")
}
/// Get the default OpenFang home directory.
/// Get the OpenFang home directory.
///
/// Priority: `OPENFANG_HOME` env var > `~/.openfang`.
pub fn openfang_home() -> PathBuf {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return PathBuf::from(home);
}
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
+104 -24
View File
@@ -687,7 +687,7 @@ impl OpenFangKernel {
}
// Initialize hand registry (curated autonomous packages)
let mut hand_registry = openfang_hands::registry::HandRegistry::new();
let hand_registry = openfang_hands::registry::HandRegistry::new();
let hand_count = hand_registry.load_bundled();
if hand_count > 0 {
info!("Loaded {hand_count} bundled hand(s)");
@@ -1017,23 +1017,24 @@ impl OpenFangKernel {
);
// Apply default_model to restored agents (same logic as spawn)
if restored_entry.manifest.model.api_key_env.is_none()
&& restored_entry.manifest.model.base_url.is_none()
{
let dm = &kernel.config.default_model;
let is_default_provider = restored_entry.manifest.model.provider.is_empty()
|| restored_entry.manifest.model.provider == "default";
let is_default_model = restored_entry.manifest.model.model.is_empty()
|| restored_entry.manifest.model.model == "default";
if is_default_provider && is_default_model {
let dm = &kernel.config.default_model;
if !dm.provider.is_empty() {
restored_entry.manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
restored_entry.manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
restored_entry.manifest.model.base_url = dm.base_url.clone();
if !dm.api_key_env.is_empty() && restored_entry.manifest.model.api_key_env.is_none() {
restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone());
}
if dm.base_url.is_some() && restored_entry.manifest.model.base_url.is_none() {
restored_entry.manifest.model.base_url.clone_from(&dm.base_url);
}
}
}
@@ -1053,6 +1054,33 @@ impl OpenFangKernel {
}
}
// If no agents exist (fresh install), spawn a default assistant
if kernel.registry.list().is_empty() {
info!("No agents found — spawning default assistant");
let dm = &kernel.config.default_model;
let manifest = AgentManifest {
name: "assistant".to_string(),
description: "General-purpose assistant".to_string(),
model: openfang_types::agent::ModelConfig {
provider: dm.provider.clone(),
model: dm.model.clone(),
system_prompt: "You are a helpful AI assistant.".to_string(),
api_key_env: if dm.api_key_env.is_empty() {
None
} else {
Some(dm.api_key_env.clone())
},
base_url: dm.base_url.clone(),
..Default::default()
},
..Default::default()
};
match kernel.spawn_agent(manifest) {
Ok(id) => info!(id = %id, "Default assistant spawned"),
Err(e) => warn!("Failed to spawn default assistant: {e}"),
}
}
// Validate routing configs against model catalog
for entry in kernel.registry.list() {
if let Some(ref routing_config) = entry.manifest.routing {
@@ -1103,29 +1131,34 @@ impl OpenFangKernel {
// Overlay kernel default_model onto agent if agent didn't explicitly choose.
// Treat empty or "default" as "use the kernel's configured default_model".
// This allows bundled agents to defer to the user's configured provider/model.
if manifest.model.api_key_env.is_none() && manifest.model.base_url.is_none() {
// Check hot-reloaded override first, fall back to boot-time config
let override_guard = self
.default_model_override
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
let dm = override_guard
.as_ref()
.unwrap_or(&self.config.default_model);
// This allows bundled agents to defer to the user's configured provider/model,
// even if the agent manifest specifies an api_key_env (which is just a hint
// about which env var to check, not a hard lock on provider/model).
{
let is_default_provider =
manifest.model.provider.is_empty() || manifest.model.provider == "default";
let is_default_model =
manifest.model.model.is_empty() || manifest.model.model == "default";
if is_default_provider && is_default_model {
// Check hot-reloaded override first, fall back to boot-time config
let override_guard = self
.default_model_override
.read()
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
let dm = override_guard
.as_ref()
.unwrap_or(&self.config.default_model);
if !dm.provider.is_empty() {
manifest.model.provider = dm.provider.clone();
}
if !dm.model.is_empty() {
manifest.model.model = dm.model.clone();
}
if dm.base_url.is_some() {
manifest.model.base_url = dm.base_url.clone();
if !dm.api_key_env.is_empty() && manifest.model.api_key_env.is_none() {
manifest.model.api_key_env = Some(dm.api_key_env.clone());
}
if dm.base_url.is_some() && manifest.model.base_url.is_none() {
manifest.model.base_url.clone_from(&dm.base_url);
}
}
}
@@ -2083,7 +2116,16 @@ impl OpenFangKernel {
routed_model = %routed_model,
"Model routing applied"
);
manifest.model.model = routed_model;
manifest.model.model = routed_model.clone();
// Also update provider if the routed model belongs to a different provider
if let Ok(cat) = self.model_catalog.read() {
if let Some(entry) = cat.find_model(&routed_model) {
if entry.provider != manifest.model.provider {
info!(old = %manifest.model.provider, new = %entry.provider, "Model routing changed provider");
manifest.model.provider = entry.provider.clone();
}
}
}
}
let driver = self.resolve_driver(&manifest)?;
@@ -2889,10 +2931,23 @@ impl OpenFangKernel {
manifest.model.system_prompt, resolved.prompt_block
);
}
if !resolved.env_vars.is_empty() {
// Collect env vars from settings + from requires (api_key/env_var requirements)
let mut allowed_env = resolved.env_vars;
for req in &def.requires {
match req.requirement_type {
openfang_hands::RequirementType::ApiKey
| openfang_hands::RequirementType::EnvVar => {
if !req.check_value.is_empty() && !allowed_env.contains(&req.check_value) {
allowed_env.push(req.check_value.clone());
}
}
_ => {}
}
}
if !allowed_env.is_empty() {
manifest.metadata.insert(
"hand_allowed_env".to_string(),
serde_json::to_value(&resolved.env_vars).unwrap_or_default(),
serde_json::to_value(&allowed_env).unwrap_or_default(),
);
}
@@ -2904,6 +2959,13 @@ impl OpenFangKernel {
);
}
// If an agent with this hand's name already exists, remove it first
let existing = self.registry.list().into_iter().find(|e| e.name == def.agent.name);
if let Some(old) = existing {
info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation");
let _ = self.kill_agent(old.id);
}
// Spawn the agent
let agent_id = self.spawn_agent(manifest)?;
@@ -4641,7 +4703,7 @@ fn infer_provider_from_model(model: &str) -> Option<String> {
/// A well-known agent ID used for shared memory operations across agents.
/// This is a fixed UUID so all agents read/write to the same namespace.
fn shared_memory_agent_id() -> AgentId {
pub fn shared_memory_agent_id() -> AgentId {
AgentId(uuid::Uuid::from_bytes([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01,
@@ -5031,6 +5093,24 @@ impl KernelHandle for OpenFangKernel {
Ok(result)
}
async fn hand_install(
&self,
toml_content: &str,
skill_content: &str,
) -> Result<serde_json::Value, String> {
let def = self
.hand_registry
.install_from_content(toml_content, skill_content)
.map_err(|e| format!("{e}"))?;
Ok(serde_json::json!({
"id": def.id,
"name": def.name,
"description": def.description,
"category": format!("{:?}", def.category),
}))
}
async fn hand_activate(
&self,
hand_id: &str,
@@ -5057,8 +5137,8 @@ impl KernelHandle for OpenFangKernel {
.ok_or_else(|| format!("No active instance found for hand '{hand_id}'"))?;
let def = self.hand_registry.get_definition(hand_id);
let def_name = def.map(|d| d.name.clone()).unwrap_or_default();
let def_icon = def.map(|d| d.icon.clone()).unwrap_or_default();
let def_name = def.as_ref().map(|d| d.name.clone()).unwrap_or_default();
let def_icon = def.as_ref().map(|d| d.icon.clone()).unwrap_or_default();
Ok(serde_json::json!({
"hand_id": hand_id,
+1 -1
View File
@@ -429,7 +429,7 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
}
// ── xAI / Grok ──────────────────────────────────────────────
if model.contains("grok-4.1") {
if model.contains("grok-4-1") {
return (0.20, 0.50);
}
if model.contains("grok-4") {
@@ -349,9 +349,9 @@ async fn test_multiple_wasm_agents() {
assert_eq!(hello_result.response, "hello from wasm");
assert!(echo_result.response.contains("test data"));
// Verify agent list shows both
// Verify agent list shows both + default assistant
let agents = kernel.registry.list();
assert_eq!(agents.len(), 2);
assert_eq!(agents.len(), 3);
kernel.shutdown();
}
@@ -391,9 +391,9 @@ memory_write = ["self.*"]
let llm_manifest: AgentManifest = toml::from_str(llm_toml).unwrap();
let llm_id = kernel.spawn_agent(llm_manifest).unwrap();
// Verify both agents exist
// Verify both agents exist + default assistant
let agents = kernel.registry.list();
assert_eq!(agents.len(), 2);
assert_eq!(agents.len(), 3);
// WASM agent should work
let result = kernel.send_message(wasm_id, "hello").await.unwrap();
@@ -404,7 +404,7 @@ memory_write = ["self.*"]
// Kill WASM agent
kernel.kill_agent(wasm_id).unwrap();
assert_eq!(kernel.registry.list().len(), 1);
assert_eq!(kernel.registry.list().len(), 2);
kernel.shutdown();
}
+10 -5
View File
@@ -91,16 +91,21 @@ impl StructuredStore {
let rows = stmt
.query_map(rusqlite::params![agent_id.0.to_string()], |row| {
let key: String = row.get(0)?;
let val_str: String = row.get(1)?;
Ok((key, val_str))
let blob: Vec<u8> = row.get(1)?;
Ok((key, blob))
})
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
let mut pairs = Vec::new();
for row in rows {
let (key, val_str) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?;
let value: serde_json::Value =
serde_json::from_str(&val_str).unwrap_or(serde_json::Value::String(val_str));
let (key, blob) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?;
let value: serde_json::Value = serde_json::from_slice(&blob)
.unwrap_or_else(|_| {
// Fallback: try as UTF-8 string
String::from_utf8(blob)
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null)
});
pairs.push((key, value));
}
Ok(pairs)
+34 -22
View File
@@ -305,32 +305,44 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
let mut content = Vec::new();
let mut tool_calls = Vec::new();
if let Some(gemini_content) = candidate.content {
for part in gemini_content.parts {
match part {
GeminiPart::Text { text } => {
if !text.is_empty() {
content.push(ContentBlock::Text { text });
match candidate.content {
Some(gemini_content) => {
for part in gemini_content.parts {
match part {
GeminiPart::Text { text } => {
if !text.is_empty() {
content.push(ContentBlock::Text { text });
}
}
GeminiPart::FunctionCall { function_call } => {
let id = format!("call_{}", uuid::Uuid::new_v4().simple());
content.push(ContentBlock::ToolUse {
id: id.clone(),
name: function_call.name.clone(),
input: function_call.args.clone(),
});
tool_calls.push(ToolCall {
id,
name: function_call.name,
input: function_call.args,
});
}
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
// Shouldn't normally appear in responses, ignore
}
}
GeminiPart::FunctionCall { function_call } => {
let id = format!("call_{}", uuid::Uuid::new_v4().simple());
content.push(ContentBlock::ToolUse {
id: id.clone(),
name: function_call.name.clone(),
input: function_call.args.clone(),
});
tool_calls.push(ToolCall {
id,
name: function_call.name,
input: function_call.args,
});
}
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
// Shouldn't normally appear in responses, ignore
}
}
}
None => {
let reason = candidate
.finish_reason
.as_deref()
.unwrap_or("unknown");
warn!(finish_reason = %reason, "Gemini returned candidate with no content");
return Err(LlmError::Parse(format!(
"Gemini returned empty response (finish_reason: {reason})"
)));
}
}
// Gemini uses "STOP" for both end-of-turn and function calls,
@@ -139,6 +139,16 @@ pub trait KernelHandle: Send + Sync {
Err("Hands system not available".to_string())
}
/// Install a Hand from TOML content.
async fn hand_install(
&self,
toml_content: &str,
skill_content: &str,
) -> Result<serde_json::Value, String> {
let _ = (toml_content, skill_content);
Err("Hands system not available".to_string())
}
/// Activate a Hand — spawns a specialized autonomous agent.
async fn hand_activate(
&self,
+81 -22
View File
@@ -648,8 +648,8 @@ fn builtin_aliases() -> HashMap<String, String> {
("gpt5", "gpt-5.2"),
("gpt5-mini", "gpt-5-mini"),
("flash", "gemini-2.5-flash"),
("gemini-flash", "gemini-2.5-flash"),
("gemini-pro", "gemini-3.1-pro"),
("gemini-pro", "gemini-3.1-pro-preview"),
("gemini-flash", "gemini-3-flash-preview"),
("deepseek", "deepseek-chat"),
("llama", "llama-3.3-70b-versatile"),
("llama-70b", "llama-3.3-70b-versatile"),
@@ -663,10 +663,11 @@ fn builtin_aliases() -> HashMap<String, String> {
("mistral-nemo", "open-mistral-nemo"),
("pixtral", "pixtral-large-latest"),
// xAI aliases
("grok", "grok-4"),
("grok", "grok-4-0709"),
("grok-4", "grok-4-0709"),
("grok-mini", "grok-2-mini"),
("grok3", "grok-3"),
("grok-fast", "grok-4.1-fast"),
("grok-fast", "grok-4-1-fast-reasoning"),
// Perplexity alias
("sonar", "sonar-pro"),
// AI21 aliases
@@ -1039,8 +1040,8 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
// Google Gemini (10)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "gemini-3.1-pro".into(),
display_name: "Gemini 3.1 Pro".into(),
id: "gemini-3.1-pro-preview".into(),
display_name: "Gemini 3.1 Pro Preview".into(),
provider: "gemini".into(),
tier: ModelTier::Frontier,
context_window: 1_048_576,
@@ -1052,16 +1053,29 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec!["gemini-pro".into()],
},
// gemini-3-flash removed: model doesn't exist. Use gemini-2.5-flash instead.
ModelCatalogEntry {
id: "gemini-3-deep-think".into(),
display_name: "Gemini 3 Deep Think".into(),
id: "gemini-3-flash-preview".into(),
display_name: "Gemini 3 Flash Preview".into(),
provider: "gemini".into(),
tier: ModelTier::Frontier,
tier: ModelTier::Smart,
context_window: 1_048_576,
max_output_tokens: 65_536,
input_cost_per_m: 2.50,
output_cost_per_m: 15.0,
input_cost_per_m: 0.15,
output_cost_per_m: 0.60,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["gemini-flash".into()],
},
ModelCatalogEntry {
id: "gemini-3.1-flash-lite-preview".into(),
display_name: "Gemini 3.1 Flash Lite Preview".into(),
provider: "gemini".into(),
tier: ModelTier::Fast,
context_window: 1_048_576,
max_output_tokens: 8_192,
input_cost_per_m: 0.04,
output_cost_per_m: 0.15,
supports_tools: true,
supports_vision: true,
supports_streaming: true,
@@ -2174,10 +2188,10 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
aliases: vec![],
},
// ══════════════════════════════════════════════════════════════
// xAI (6)
// xAI (9)
// ══════════════════════════════════════════════════════════════
ModelCatalogEntry {
id: "grok-4".into(),
id: "grok-4-0709".into(),
display_name: "Grok 4".into(),
provider: "xai".into(),
tier: ModelTier::Frontier,
@@ -2188,11 +2202,39 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_tools: true,
supports_vision: true,
supports_streaming: true,
aliases: vec!["grok".into()],
aliases: vec!["grok".into(), "grok-4".into()],
},
ModelCatalogEntry {
id: "grok-4.1-fast".into(),
display_name: "Grok 4.1 Fast".into(),
id: "grok-4-fast-reasoning".into(),
display_name: "Grok 4 Fast Reasoning".into(),
provider: "xai".into(),
tier: ModelTier::Smart,
context_window: 256_000,
max_output_tokens: 32_768,
input_cost_per_m: 1.0,
output_cost_per_m: 5.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "grok-4-fast-non-reasoning".into(),
display_name: "Grok 4 Fast Non-Reasoning".into(),
provider: "xai".into(),
tier: ModelTier::Smart,
context_window: 256_000,
max_output_tokens: 32_768,
input_cost_per_m: 1.0,
output_cost_per_m: 5.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "grok-4-1-fast-reasoning".into(),
display_name: "Grok 4.1 Fast Reasoning".into(),
provider: "xai".into(),
tier: ModelTier::Fast,
context_window: 2_000_000,
@@ -2204,6 +2246,20 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec!["grok-fast".into()],
},
ModelCatalogEntry {
id: "grok-4-1-fast-non-reasoning".into(),
display_name: "Grok 4.1 Fast Non-Reasoning".into(),
provider: "xai".into(),
tier: ModelTier::Fast,
context_window: 2_000_000,
max_output_tokens: 32_768,
input_cost_per_m: 0.20,
output_cost_per_m: 0.50,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "grok-3".into(),
display_name: "Grok 3".into(),
@@ -3020,7 +3076,7 @@ mod tests {
assert!(aliases.len() >= 20);
assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-6");
// New aliases
assert_eq!(aliases.get("grok").unwrap(), "grok-4");
assert_eq!(aliases.get("grok").unwrap(), "grok-4-0709");
assert_eq!(aliases.get("jamba").unwrap(), "jamba-1.5-large");
}
@@ -3028,7 +3084,7 @@ mod tests {
fn test_find_grok_by_alias() {
let catalog = ModelCatalog::new();
let entry = catalog.find_model("grok").unwrap();
assert_eq!(entry.id, "grok-4");
assert_eq!(entry.id, "grok-4-0709");
assert_eq!(entry.provider, "xai");
}
@@ -3049,9 +3105,12 @@ mod tests {
fn test_xai_models() {
let catalog = ModelCatalog::new();
let xai = catalog.models_by_provider("xai");
assert_eq!(xai.len(), 6);
assert!(xai.iter().any(|m| m.id == "grok-4"));
assert!(xai.iter().any(|m| m.id == "grok-4.1-fast"));
assert_eq!(xai.len(), 9);
assert!(xai.iter().any(|m| m.id == "grok-4-0709"));
assert!(xai.iter().any(|m| m.id == "grok-4-fast-reasoning"));
assert!(xai.iter().any(|m| m.id == "grok-4-fast-non-reasoning"));
assert!(xai.iter().any(|m| m.id == "grok-4-1-fast-reasoning"));
assert!(xai.iter().any(|m| m.id == "grok-4-1-fast-non-reasoning"));
assert!(xai.iter().any(|m| m.id == "grok-3"));
assert!(xai.iter().any(|m| m.id == "grok-3-mini"));
assert!(xai.iter().any(|m| m.id == "grok-2"));
+2 -2
View File
@@ -627,7 +627,7 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"type": "object",
"properties": {
"key": { "type": "string", "description": "The storage key" },
"value": { "description": "The JSON value to store (any type)" }
"value": { "type": "string", "description": "The value to store (JSON-encode objects/arrays, or pass a plain string)" }
},
"required": ["key", "value"]
}),
@@ -705,7 +705,7 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
"type": "object",
"properties": {
"event_type": { "type": "string", "description": "Type identifier for the event (e.g., 'code_review_requested')" },
"payload": { "description": "JSON payload data for the event" }
"payload": { "type": "object", "description": "JSON payload data for the event" }
},
"required": ["event_type"]
}),
+11 -4
View File
@@ -1168,7 +1168,7 @@ fn default_language() -> String {
impl Default for KernelConfig {
fn default() -> Self {
let home_dir = dirs_next_home().join(".openfang");
let home_dir = openfang_home_dir();
Self {
data_dir: home_dir.join("data"),
home_dir,
@@ -1308,9 +1308,16 @@ impl std::fmt::Debug for KernelConfig {
}
}
/// Fallback home directory resolution.
fn dirs_next_home() -> PathBuf {
dirs::home_dir().unwrap_or_else(std::env::temp_dir)
/// Resolve the OpenFang home directory.
///
/// Priority: `OPENFANG_HOME` env var > `~/.openfang`.
fn openfang_home_dir() -> PathBuf {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return PathBuf::from(home);
}
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
}
/// Default LLM model configuration.