mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ac992420 | ||
|
|
53e1b31777 | ||
|
|
603a94e560 | ||
|
|
fac4ad33e5 | ||
|
|
fe96cd1004 | ||
|
|
7c85308cf6 |
Generated
+14
-14
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">🤖</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">🔧</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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 `<...>`.
|
||||
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("<");
|
||||
result.push_str(tag_content);
|
||||
result.push_str(">");
|
||||
}
|
||||
// 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("<");
|
||||
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
|
||||
);
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"]
|
||||
}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user