mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-17 18:32:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
444d82e4d6 | ||
|
|
62e6e0f088 | ||
|
|
d3385f2cdc | ||
|
|
516f163dfb | ||
|
|
a54bb1cd4f | ||
|
|
6f3c4e7778 | ||
|
|
73ad49a3a1 | ||
|
|
7ec0e024c7 |
Generated
+15
-14
@@ -3866,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3902,7 +3902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3933,7 +3933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3960,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3986,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4014,7 +4014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4031,7 +4031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4067,7 +4067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4086,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4105,7 +4105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4128,6 +4128,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
@@ -4136,7 +4137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4158,7 +4159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4177,7 +4178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -8789,7 +8790,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.4"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -82,6 +82,10 @@ pub async fn auth(
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
let path = request.uri().path();
|
||||
if path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| path == "/.well-known/agent.json"
|
||||
|| path.starts_with("/a2a/")
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
@@ -119,15 +123,22 @@ pub async fn auth(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check Authorization: Bearer <token> header
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
let api_token = bearer_token.or_else(|| {
|
||||
request
|
||||
.headers()
|
||||
.get("x-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
});
|
||||
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
||||
let header_auth = bearer_token.map(|token| {
|
||||
let header_auth = api_token.map(|token| {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != api_key.len() {
|
||||
return false;
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct AppState {
|
||||
pub channels_config: tokio::sync::RwLock<openfang_types::config::ChannelsConfig>,
|
||||
/// Notify handle to trigger graceful HTTP server shutdown from the API.
|
||||
pub shutdown_notify: Arc<tokio::sync::Notify>,
|
||||
/// ClawHub response cache — prevents 429 rate limiting on rapid dashboard refreshes.
|
||||
/// Maps cache key → (fetched_at, response_json) with 120s TTL.
|
||||
pub clawhub_cache: DashMap<String, (Instant, serde_json::Value)>,
|
||||
}
|
||||
|
||||
/// POST /api/agents — Spawn a new agent.
|
||||
@@ -1120,7 +1123,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
|
||||
ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false },
|
||||
ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true },
|
||||
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
|
||||
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "33280", advanced: true },
|
||||
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true },
|
||||
],
|
||||
setup_steps: &["Go to discord.com/developers/applications", "Create a bot and copy the token", "Paste it below"],
|
||||
config_template: "[channels.discord]\nbot_token_env = \"DISCORD_BOT_TOKEN\"",
|
||||
@@ -2782,6 +2785,14 @@ pub async fn clawhub_search(
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20);
|
||||
|
||||
// Check cache (120s TTL)
|
||||
let cache_key = format!("search:{}:{}", query, limit);
|
||||
if let Some(entry) = state.clawhub_cache.get(&cache_key) {
|
||||
if entry.0.elapsed().as_secs() < 120 {
|
||||
return (StatusCode::OK, Json(entry.1.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub");
|
||||
let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir);
|
||||
|
||||
@@ -2801,20 +2812,26 @@ pub async fn clawhub_search(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"items": items,
|
||||
"next_cursor": null,
|
||||
})),
|
||||
)
|
||||
let resp = serde_json::json!({
|
||||
"items": items,
|
||||
"next_cursor": null,
|
||||
});
|
||||
state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone()));
|
||||
(StatusCode::OK, Json(resp))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ClawHub search failed: {e}");
|
||||
let msg = format!("{e}");
|
||||
tracing::warn!("ClawHub search failed: {msg}");
|
||||
// Propagate 429 status instead of masking as 200
|
||||
let status = if msg.contains("429") || msg.contains("rate limit") {
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
status,
|
||||
Json(
|
||||
serde_json::json!({"items": [], "next_cursor": null, "error": format!("{e}")}),
|
||||
serde_json::json!({"items": [], "next_cursor": null, "error": msg}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2846,6 +2863,14 @@ pub async fn clawhub_browse(
|
||||
|
||||
let cursor = params.get("cursor").map(|s| s.as_str());
|
||||
|
||||
// Check cache (120s TTL)
|
||||
let cache_key = format!("browse:{:?}:{}:{}", sort, limit, cursor.unwrap_or(""));
|
||||
if let Some(entry) = state.clawhub_cache.get(&cache_key) {
|
||||
if entry.0.elapsed().as_secs() < 120 {
|
||||
return (StatusCode::OK, Json(entry.1.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub");
|
||||
let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir);
|
||||
|
||||
@@ -2856,20 +2881,25 @@ pub async fn clawhub_browse(
|
||||
.iter()
|
||||
.map(clawhub_browse_entry_to_json)
|
||||
.collect();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"items": items,
|
||||
"next_cursor": results.next_cursor,
|
||||
})),
|
||||
)
|
||||
let resp = serde_json::json!({
|
||||
"items": items,
|
||||
"next_cursor": results.next_cursor,
|
||||
});
|
||||
state.clawhub_cache.insert(cache_key, (Instant::now(), resp.clone()));
|
||||
(StatusCode::OK, Json(resp))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ClawHub browse failed: {e}");
|
||||
let msg = format!("{e}");
|
||||
tracing::warn!("ClawHub browse failed: {msg}");
|
||||
let status = if msg.contains("429") || msg.contains("rate limit") {
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
status,
|
||||
Json(
|
||||
serde_json::json!({"items": [], "next_cursor": null, "error": format!("{e}")}),
|
||||
serde_json::json!({"items": [], "next_cursor": null, "error": msg}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2992,13 +3022,18 @@ pub async fn clawhub_install(
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
let status = if e.to_string().contains("SecurityBlocked") {
|
||||
let msg = format!("{e}");
|
||||
let status = if msg.contains("SecurityBlocked") {
|
||||
StatusCode::FORBIDDEN
|
||||
} else if msg.contains("429") || msg.contains("rate limit") {
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
} else if msg.contains("Network error") || msg.contains("returned 4") || msg.contains("returned 5") {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
tracing::warn!("ClawHub install failed: {e}");
|
||||
(status, Json(serde_json::json!({"error": format!("{e}")})))
|
||||
tracing::warn!("ClawHub install failed: {msg}");
|
||||
(status, Json(serde_json::json!({"error": msg})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4352,6 +4387,55 @@ pub async fn agent_budget_ranking(State(state): State<Arc<AppState>>) -> impl In
|
||||
Json(serde_json::json!({"agents": agents, "total": agents.len()}))
|
||||
}
|
||||
|
||||
/// PUT /api/budget/agents/{id} — Update per-agent budget limits at runtime.
|
||||
pub async fn update_agent_budget(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid agent ID"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let hourly = body["max_cost_per_hour_usd"].as_f64();
|
||||
let daily = body["max_cost_per_day_usd"].as_f64();
|
||||
let monthly = body["max_cost_per_month_usd"].as_f64();
|
||||
|
||||
if hourly.is_none() && daily.is_none() && monthly.is_none() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Provide at least one of: max_cost_per_hour_usd, max_cost_per_day_usd, max_cost_per_month_usd"})),
|
||||
);
|
||||
}
|
||||
|
||||
match state
|
||||
.kernel
|
||||
.registry
|
||||
.update_resources(agent_id, hourly, daily, monthly)
|
||||
{
|
||||
Ok(()) => {
|
||||
// Persist updated entry
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let _ = state.kernel.memory.save_agent(&entry);
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "message": "Agent budget updated"})),
|
||||
)
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session listing endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5632,6 +5716,32 @@ pub async fn reset_session(
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/agents/{id}/history — Clear ALL conversation history for an agent.
|
||||
pub async fn clear_agent_history(
|
||||
State(state): State<Arc<AppState>>,
|
||||
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"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
match state.kernel.clear_agent_history(agent_id) {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "message": "All history cleared"})),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/agents/{id}/session/compact — Trigger LLM session compaction.
|
||||
pub async fn compact_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -5724,6 +5834,92 @@ pub async fn set_model(
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/agents/{id}/tools — Get an agent's tool allowlist/blocklist.
|
||||
pub async fn get_agent_tools(
|
||||
State(state): State<Arc<AppState>>,
|
||||
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 entry = match state.kernel.registry.get(agent_id) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"tool_allowlist": entry.manifest.tool_allowlist,
|
||||
"tool_blocklist": entry.manifest.tool_blocklist,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// PUT /api/agents/{id}/tools — Update an agent's tool allowlist/blocklist.
|
||||
pub async fn set_agent_tools(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid agent ID"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
let allowlist = body
|
||||
.get("tool_allowlist")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let blocklist = body
|
||||
.get("tool_blocklist")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
if allowlist.is_none() && blocklist.is_none() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Provide 'tool_allowlist' and/or 'tool_blocklist'"})),
|
||||
);
|
||||
}
|
||||
|
||||
match state
|
||||
.kernel
|
||||
.set_agent_tool_filters(agent_id, allowlist, blocklist)
|
||||
{
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok"})),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-Agent Skill & MCP Endpoints ────────────────────────────────────
|
||||
|
||||
/// GET /api/agents/{id}/skills — Get an agent's skill assignment info.
|
||||
@@ -6034,14 +6230,25 @@ pub async fn test_provider(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let (env_var, base_url, key_required) = {
|
||||
let (env_var, base_url, key_required, default_model) = {
|
||||
let catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
match catalog.get_provider(&name) {
|
||||
Some(p) => (p.api_key_env.clone(), p.base_url.clone(), p.key_required),
|
||||
Some(p) => {
|
||||
// Find a default model for this provider to use in the test request
|
||||
let model_id = catalog
|
||||
.default_model_for_provider(&name)
|
||||
.unwrap_or_default();
|
||||
(
|
||||
p.api_key_env.clone(),
|
||||
p.base_url.clone(),
|
||||
p.key_required,
|
||||
model_id,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -6076,7 +6283,7 @@ pub async fn test_provider(
|
||||
Ok(driver) => {
|
||||
// Send a minimal completion request to test connectivity
|
||||
let test_req = openfang_runtime::llm_driver::CompletionRequest {
|
||||
model: String::new(), // Driver will use default
|
||||
model: default_model.clone(),
|
||||
messages: vec![openfang_types::message::Message::user("Hi")],
|
||||
tools: vec![],
|
||||
max_tokens: 1,
|
||||
@@ -6785,6 +6992,29 @@ pub async fn create_schedule(
|
||||
}
|
||||
|
||||
let agent_id_str = req["agent_id"].as_str().unwrap_or("").to_string();
|
||||
if agent_id_str.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Missing required field: agent_id"})),
|
||||
);
|
||||
}
|
||||
// Validate agent exists (UUID or name lookup)
|
||||
let agent_exists = if let Ok(aid) = agent_id_str.parse::<AgentId>() {
|
||||
state.kernel.registry.get(aid).is_some()
|
||||
} else {
|
||||
state
|
||||
.kernel
|
||||
.registry
|
||||
.list()
|
||||
.iter()
|
||||
.any(|a| a.name == agent_id_str)
|
||||
};
|
||||
if !agent_exists {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Agent not found: {agent_id_str}")})),
|
||||
);
|
||||
}
|
||||
let message = req["message"].as_str().unwrap_or("").to_string();
|
||||
let enabled = req.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
@@ -6958,10 +7188,14 @@ pub async fn run_schedule(
|
||||
.unwrap_or("Scheduled task triggered manually.");
|
||||
let name = schedule["name"].as_str().unwrap_or("(unnamed)");
|
||||
|
||||
// Find the target agent
|
||||
// Find the target agent — require explicit agent_id, no silent fallback
|
||||
let target_agent = if !agent_id_str.is_empty() {
|
||||
if let Ok(aid) = agent_id_str.parse::<AgentId>() {
|
||||
Some(aid)
|
||||
if state.kernel.registry.get(aid).is_some() {
|
||||
Some(aid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
state
|
||||
.kernel
|
||||
@@ -6972,7 +7206,7 @@ pub async fn run_schedule(
|
||||
.map(|a| a.id)
|
||||
}
|
||||
} else {
|
||||
state.kernel.registry.list().first().map(|a| a.id)
|
||||
None
|
||||
};
|
||||
|
||||
let target_agent = match target_agent {
|
||||
@@ -7013,7 +7247,8 @@ pub async fn run_schedule(
|
||||
serde_json::Value::Array(schedules_updated),
|
||||
);
|
||||
|
||||
match state.kernel.send_message(target_agent, &run_message).await {
|
||||
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state.kernel.send_message_with_handle(target_agent, &run_message, Some(kernel_handle)).await {
|
||||
Ok(result) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
@@ -7333,6 +7568,13 @@ pub async fn patch_agent_config(
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updated manifest to database so changes survive restart
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
if let Err(e) = state.kernel.memory.save_agent(&entry) {
|
||||
tracing::warn!("Failed to persist agent config update: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "agent_id": id})),
|
||||
@@ -8146,7 +8388,19 @@ pub async fn config_reload(State(state): State<Arc<AppState>>) -> impl IntoRespo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/config/schema — Return a simplified JSON description of the config structure.
|
||||
pub async fn config_schema() -> impl IntoResponse {
|
||||
pub async fn config_schema(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
// Build provider/model options from model catalog for dropdowns
|
||||
let catalog = state.kernel.model_catalog.read().unwrap_or_else(|e| e.into_inner());
|
||||
let provider_options: Vec<String> = catalog.list_providers().iter().map(|p| p.id.clone()).collect();
|
||||
let model_options: Vec<serde_json::Value> = catalog
|
||||
.list_models()
|
||||
.iter()
|
||||
.map(|m| serde_json::json!({"id": m.id, "name": m.display_name, "provider": m.provider}))
|
||||
.collect();
|
||||
drop(catalog);
|
||||
|
||||
Json(serde_json::json!({
|
||||
"sections": {
|
||||
"api": {
|
||||
@@ -8157,9 +8411,10 @@ pub async fn config_schema() -> impl IntoResponse {
|
||||
}
|
||||
},
|
||||
"default_model": {
|
||||
"hot_reloadable": true,
|
||||
"fields": {
|
||||
"provider": "string",
|
||||
"model": "string",
|
||||
"provider": { "type": "select", "options": provider_options },
|
||||
"model": { "type": "select", "options": model_options },
|
||||
"api_key_env": "string",
|
||||
"base_url": "string"
|
||||
}
|
||||
@@ -9253,39 +9508,46 @@ fn audit_to_comms_event(
|
||||
};
|
||||
|
||||
let action_str = format!("{:?}", entry.action);
|
||||
let (kind, detail) = match action_str.as_str() {
|
||||
"AgentMessage" => (
|
||||
CommsEventKind::AgentMessage,
|
||||
openfang_types::truncate_str(&entry.detail, 200).to_string(),
|
||||
),
|
||||
let (kind, detail, target_label) = match action_str.as_str() {
|
||||
"AgentMessage" => {
|
||||
// Format detail: "tokens_in=X, tokens_out=Y" → readable summary
|
||||
let detail = if entry.detail.starts_with("tokens_in=") {
|
||||
let parts: Vec<&str> = entry.detail.split(", ").collect();
|
||||
let in_tok = parts.first().and_then(|p| p.strip_prefix("tokens_in=")).unwrap_or("?");
|
||||
let out_tok = parts.get(1).and_then(|p| p.strip_prefix("tokens_out=")).unwrap_or("?");
|
||||
if entry.outcome == "ok" {
|
||||
format!("{} in / {} out tokens", in_tok, out_tok)
|
||||
} else {
|
||||
format!("{} in / {} out — {}", in_tok, out_tok, openfang_types::truncate_str(&entry.outcome, 80))
|
||||
}
|
||||
} else if entry.outcome != "ok" {
|
||||
format!("{} — {}", openfang_types::truncate_str(&entry.detail, 80), openfang_types::truncate_str(&entry.outcome, 80))
|
||||
} else {
|
||||
openfang_types::truncate_str(&entry.detail, 200).to_string()
|
||||
};
|
||||
(CommsEventKind::AgentMessage, detail, "user")
|
||||
}
|
||||
"AgentSpawn" => (
|
||||
CommsEventKind::AgentSpawned,
|
||||
format!("Agent spawned: {}", openfang_types::truncate_str(&entry.detail, 100)),
|
||||
"",
|
||||
),
|
||||
"AgentKill" => (
|
||||
CommsEventKind::AgentTerminated,
|
||||
format!("Agent killed: {}", openfang_types::truncate_str(&entry.detail, 100)),
|
||||
"",
|
||||
),
|
||||
"ToolInvoke" => return None,
|
||||
"CapabilityCheck" => return None,
|
||||
"MemoryAccess" => return None,
|
||||
"FileAccess" => return None,
|
||||
"NetworkAccess" => return None,
|
||||
"ShellExec" => return None,
|
||||
"AuthAttempt" => return None,
|
||||
"WireConnect" => return None,
|
||||
"ConfigChange" => return None,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(CommsEvent {
|
||||
id: entry.seq.to_string(),
|
||||
id: format!("audit-{}", entry.seq),
|
||||
timestamp: entry.timestamp.clone(),
|
||||
kind,
|
||||
source_id: entry.agent_id.clone(),
|
||||
source_name: resolve_name(&entry.agent_id),
|
||||
target_id: String::new(),
|
||||
target_name: String::new(),
|
||||
target_id: if target_label.is_empty() { String::new() } else { target_label.to_string() },
|
||||
target_name: if target_label.is_empty() { String::new() } else { target_label.to_string() },
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub async fn build_router(
|
||||
bridge_manager: tokio::sync::Mutex::new(bridge),
|
||||
channels_config: tokio::sync::RwLock::new(channels_config),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
@@ -156,6 +157,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/session/reset",
|
||||
axum::routing::post(routes::reset_session),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/history",
|
||||
axum::routing::delete(routes::clear_agent_history),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/session/compact",
|
||||
axum::routing::post(routes::compact_session),
|
||||
@@ -168,6 +173,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/model",
|
||||
axum::routing::put(routes::set_model),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/tools",
|
||||
axum::routing::get(routes::get_agent_tools).put(routes::set_agent_tools),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/skills",
|
||||
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
|
||||
@@ -447,7 +456,8 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/budget/agents/{id}",
|
||||
axum::routing::get(routes::agent_budget_status),
|
||||
axum::routing::get(routes::agent_budget_status)
|
||||
.put(routes::update_agent_budget),
|
||||
)
|
||||
// Session endpoints
|
||||
.route("/api/sessions", axum::routing::get(routes::list_sessions))
|
||||
|
||||
@@ -569,7 +569,7 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="$store.app.toggleFocusMode()" title="Ctrl+Shift+F">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><template x-if="!$store.app.focusMode"><g><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g></template><template x-if="$store.app.focusMode"><g><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></template></svg>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g x-show="!$store.app.focusMode"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g><g x-show="$store.app.focusMode"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></svg>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
|
||||
</div>
|
||||
@@ -696,6 +696,16 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Model autocomplete picker -->
|
||||
<div x-show="showModelPicker && filteredModelPicker.length" class="slash-menu" style="max-height:280px;overflow-y:auto">
|
||||
<div class="text-xs text-dim" style="padding:4px 10px;border-bottom:1px solid var(--border)">Available models — pick one or keep typing</div>
|
||||
<template x-for="(m, idx) in filteredModelPicker" :key="m.id">
|
||||
<div class="slash-menu-item" :class="{ 'slash-active': idx === modelPickerIdx }" @click="pickModel(m.id)" @mouseenter="modelPickerIdx = idx">
|
||||
<span class="font-bold" style="font-size:12px;font-family:var(--font-mono)" x-text="m.id"></span>
|
||||
<span class="text-xs text-dim" x-text="m.provider + (m.display_name && m.display_name !== m.id ? ' · ' + m.display_name : '')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Input row -->
|
||||
<div class="input-row">
|
||||
<button class="btn btn-ghost btn-sm" @click="$refs.fileInput.click()" title="Attach file" style="padding:6px 8px;flex-shrink:0">
|
||||
@@ -713,10 +723,10 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false"
|
||||
@keydown.arrow-up.prevent="if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@input="$el.style.height='auto';$el.style.height=Math.min($el.scrollHeight,150)+'px'"
|
||||
x-model="inputText"
|
||||
:class="{ 'streaming-active': sending }"></textarea>
|
||||
@@ -811,7 +821,7 @@
|
||||
<div class="tabs" style="margin-bottom:16px">
|
||||
<div class="tab" :class="{ active: detailTab === 'info' }" @click="detailTab = 'info'">Info</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'files' }" @click="detailTab = 'files'; loadAgentFiles()">Files</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'">Config</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'; loadToolFilters()">Config</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Info -->
|
||||
@@ -826,12 +836,29 @@
|
||||
</div>
|
||||
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Model</span><span class="detail-value" x-text="detailAgent.model_name"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Model</span>
|
||||
<template x-if="!editingModel">
|
||||
<span>
|
||||
<span class="detail-value" x-text="detailAgent.model_name"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingModel = true; newModelValue = detailAgent.model_provider + '/' + detailAgent.model_name">Change</button>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="editingModel">
|
||||
<span class="flex gap-1" style="align-items:center">
|
||||
<input class="form-input" style="width:240px;font-size:12px" x-model="newModelValue" placeholder="provider/model" @keydown.enter="changeModel()" @keydown.escape="editingModel = false">
|
||||
<button class="btn btn-primary btn-sm" @click="changeModel()" :disabled="modelSaving" style="padding:2px 10px">
|
||||
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingModel = false" style="padding:2px 8px">Cancel</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
||||
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
|
||||
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
|
||||
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -890,6 +917,36 @@
|
||||
<button class="btn btn-primary mt-4" @click="saveConfig()" :disabled="configSaving">
|
||||
<span x-show="!configSaving">Save Config</span><span x-show="configSaving">Saving...</span>
|
||||
</button>
|
||||
|
||||
<!-- Tool Filters -->
|
||||
<div class="mt-4" style="border-top:1px solid var(--border);padding-top:16px">
|
||||
<h4 style="margin-bottom:8px;font-size:13px">Tool Filters</h4>
|
||||
<p class="text-xs text-dim" style="margin-bottom:12px">Allowlist: only these tools available (empty = all). Blocklist: these tools excluded.</p>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px">Allowlist <span class="text-dim" x-text="'(' + toolFilters.tool_allowlist.length + ')'"></span></label>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<template x-for="(t, i) in toolFilters.tool_allowlist" :key="'al-'+i">
|
||||
<span class="badge" style="cursor:pointer" @click="removeAllowTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> ×</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<input class="form-input" style="font-size:12px;flex:1" x-model="newAllowTool" placeholder="tool name" @keydown.enter="addAllowTool()">
|
||||
<button class="btn btn-ghost btn-sm" @click="addAllowTool()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px">Blocklist <span class="text-dim" x-text="'(' + toolFilters.tool_blocklist.length + ')'"></span></label>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<template x-for="(t, i) in toolFilters.tool_blocklist" :key="'bl-'+i">
|
||||
<span class="badge badge-danger" style="cursor:pointer" @click="removeBlockTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> ×</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<input class="form-input" style="font-size:12px;flex:1" x-model="newBlockTool" placeholder="tool name" @keydown.enter="addBlockTool()">
|
||||
<button class="btn btn-ghost btn-sm" @click="addBlockTool()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1292,63 +1349,8 @@
|
||||
</defs>
|
||||
<rect x="-2000" y="-2000" width="6000" height="6000" fill="url(#wf-grid)"/>
|
||||
|
||||
<!-- Connections -->
|
||||
<template x-for="conn in connections" :key="conn.id">
|
||||
<g>
|
||||
<path :d="getConnectionPath(conn)" fill="none" stroke="var(--text-dim)" stroke-width="2"
|
||||
style="cursor:pointer" @click.stop="selectedConnection = conn"
|
||||
:stroke="selectedConnection && selectedConnection.id === conn.id ? 'var(--accent)' : 'var(--text-dim)'"
|
||||
:stroke-width="selectedConnection && selectedConnection.id === conn.id ? 3 : 2"/>
|
||||
<!-- Arrow at midpoint -->
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<!-- Connection preview line -->
|
||||
<template x-if="connecting && connectPreview">
|
||||
<path :d="getPreviewPath()" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="6,3"/>
|
||||
</template>
|
||||
|
||||
<!-- Nodes -->
|
||||
<template x-for="node in nodes" :key="node.id">
|
||||
<g class="wf-node" :transform="'translate(' + node.x + ',' + node.y + ')'"
|
||||
@mousedown="onNodeMouseDown(node, $event)" @dblclick="editNode(node)">
|
||||
<!-- Node body -->
|
||||
<rect x="0" y="0" :width="node.width" :height="node.height" rx="8" ry="8"
|
||||
:fill="selectedNode && selectedNode.id === node.id ? 'var(--card-bg)' : 'var(--bg-secondary)'"
|
||||
:stroke="selectedNode && selectedNode.id === node.id ? node.color : 'var(--border)'"
|
||||
stroke-width="2" style="cursor:grab"/>
|
||||
<!-- Color accent bar -->
|
||||
<rect x="0" y="0" width="6" :height="node.height" rx="3" ry="0" :fill="node.color"/>
|
||||
<!-- Icon -->
|
||||
<circle cx="28" :cy="node.height/2" r="14" :fill="node.color" opacity="0.15"/>
|
||||
<text x="28" :y="node.height/2 + 4" text-anchor="middle" :fill="node.color"
|
||||
style="font-size:12px;font-weight:700;pointer-events:none" x-text="node.icon"></text>
|
||||
<!-- Label -->
|
||||
<text x="50" :y="node.height/2 - 4" fill="var(--text)" style="font-size:12px;font-weight:600;pointer-events:none"
|
||||
x-text="node.label"></text>
|
||||
<!-- Sub-label (config hint) -->
|
||||
<text x="50" :y="node.height/2 + 12" fill="var(--text-dim)" style="font-size:10px;pointer-events:none"
|
||||
x-text="node.type === 'agent' ? (node.config.agent_name || 'No agent') :
|
||||
node.type === 'condition' ? (node.config.expression || 'No condition') :
|
||||
node.type === 'loop' ? ('max ' + (node.config.max_iterations || 5) + ' iters') :
|
||||
node.type === 'parallel' ? (node.config.fan_count + ' branches') :
|
||||
node.type === 'collect' ? node.config.strategy : ''"></text>
|
||||
|
||||
<!-- Input ports -->
|
||||
<template x-for="pi in Array.from({length: node.ports.in}, function(_,i){return i})" :key="'in-'+pi">
|
||||
<circle class="wf-port wf-port-in" :cx="node.width / (node.ports.in + 1) * (pi + 1)" cy="0" r="6"
|
||||
fill="var(--bg-secondary)" stroke="var(--text-dim)" stroke-width="2"
|
||||
@mouseup.stop="endConnect(node.id, pi, $event)"/>
|
||||
</template>
|
||||
|
||||
<!-- Output ports -->
|
||||
<template x-for="po in Array.from({length: node.ports.out}, function(_,i){return i})" :key="'out-'+po">
|
||||
<circle class="wf-port wf-port-out" :cx="node.width / (node.ports.out + 1) * (po + 1)" :cy="node.height" r="6"
|
||||
fill="var(--bg-secondary)" :stroke="node.color" stroke-width="2"
|
||||
@mousedown.stop="startConnect(node.id, po, $event)"/>
|
||||
</template>
|
||||
</g>
|
||||
</template>
|
||||
<!-- Manually rendered nodes & connections (Alpine x-for breaks inside SVG) -->
|
||||
<g id="wf-render-group" x-effect="nodes.length; connections.length; selectedNode; selectedConnection; connecting; connectPreview; scheduleRender()"></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@@ -2405,7 +2407,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<span style="font-size:1.4rem" x-text="getHandIcon(inst.hand_id)"></span>
|
||||
<div class="card-header" style="margin:0" x-text="inst.agent_name || inst.hand_id"></div>
|
||||
</div>
|
||||
<span class="badge" :class="inst.status === 'Active' ? 'badge-success' : inst.status === 'Paused' ? 'badge-dim' : 'badge-info'" x-text="inst.status"></span>
|
||||
<span class="badge" :class="{ 'badge-success': inst.status === 'Active', 'badge-dim': inst.status === 'Paused', 'badge-warn': inst.status && inst.status.startsWith('Error'), 'badge-info': inst.status === 'Inactive' }" x-text="inst.status"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim" x-text="'Activated: ' + new Date(inst.activated_at).toLocaleString()"></div>
|
||||
<div class="text-xs text-dim" x-show="inst.agent_id" x-text="'Agent: ' + inst.agent_id"></div>
|
||||
@@ -2432,6 +2434,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<template x-if="inst.status === 'Paused'">
|
||||
<button class="btn btn-ghost btn-sm" @click="resumeHand(inst)">Resume</button>
|
||||
</template>
|
||||
<template x-if="inst.status && inst.status.startsWith('Error')">
|
||||
<span class="text-xs text-dim">Error — deactivate and reactivate</span>
|
||||
</template>
|
||||
<button class="btn btn-danger btn-sm" @click="deactivate(inst)">Deactivate</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2995,7 +3000,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<tbody>
|
||||
<template x-for="m in filteredModels" :key="m.id">
|
||||
<tr>
|
||||
<td class="font-bold" style="font-size:11px" x-text="m.display_name || m.id"></td>
|
||||
<td style="font-size:11px"><div class="font-bold" x-text="m.display_name || m.id"></div><div class="text-xs text-dim" style="font-family:var(--font-mono);opacity:0.7;user-select:all" x-show="m.display_name && m.display_name !== m.id" x-text="m.id"></div></td>
|
||||
<td class="text-dim" x-text="m.provider"></td>
|
||||
<td><span class="tier-badge" :class="tierBadgeClass(m.tier)" x-text="m.tier || '-'"></span></td>
|
||||
<td class="text-xs" x-text="formatContext(m.context_window)"></td>
|
||||
|
||||
@@ -54,6 +54,15 @@ function agentsPage() {
|
||||
filesLoading: false,
|
||||
configForm: {},
|
||||
configSaving: false,
|
||||
// -- Tool filters --
|
||||
toolFilters: { tool_allowlist: [], tool_blocklist: [] },
|
||||
toolFiltersLoading: false,
|
||||
newAllowTool: '',
|
||||
newBlockTool: '',
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
modelSaving: false,
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -559,6 +568,88 @@ function agentsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Clear agent history ──
|
||||
async clearHistory(agent) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Clear History', 'Clear all conversation history for "' + agent.name + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/agents/' + agent.id + '/history');
|
||||
OpenFangToast.success('History cleared for "' + agent.name + '"');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to clear history: ' + e.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Model switch ──
|
||||
async changeModel() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
OpenFangToast.success('Model changed (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
var agents = Alpine.store('app').agents;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to change model: ' + e.message);
|
||||
}
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Tool filters ──
|
||||
async loadToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
this.toolFiltersLoading = true;
|
||||
try {
|
||||
this.toolFilters = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/tools');
|
||||
} catch(e) {
|
||||
this.toolFilters = { tool_allowlist: [], tool_blocklist: [] };
|
||||
}
|
||||
this.toolFiltersLoading = false;
|
||||
},
|
||||
|
||||
addAllowTool() {
|
||||
var t = this.newAllowTool.trim();
|
||||
if (t && this.toolFilters.tool_allowlist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_allowlist.push(t);
|
||||
this.newAllowTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeAllowTool(tool) {
|
||||
this.toolFilters.tool_allowlist = this.toolFilters.tool_allowlist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
addBlockTool() {
|
||||
var t = this.newBlockTool.trim();
|
||||
if (t && this.toolFilters.tool_blocklist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_blocklist.push(t);
|
||||
this.newBlockTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeBlockTool(tool) {
|
||||
this.toolFilters.tool_blocklist = this.toolFilters.tool_blocklist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
async saveToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/tools', this.toolFilters);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update tool filters: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async spawnBuiltin(t) {
|
||||
var toml = 'name = "' + t.name + '"\n';
|
||||
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
|
||||
|
||||
@@ -29,6 +29,11 @@ function chatPage() {
|
||||
_audioChunks: [],
|
||||
recordingTime: 0,
|
||||
_recordingTimer: null,
|
||||
// Model autocomplete state
|
||||
showModelPicker: false,
|
||||
modelPickerList: [],
|
||||
modelPickerFilter: '',
|
||||
modelPickerIdx: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
@@ -126,18 +131,47 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
if (val.startsWith('/')) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
if (modelMatch) {
|
||||
self.showSlashMenu = false;
|
||||
self.modelPickerFilter = modelMatch[1].toLowerCase();
|
||||
if (!self.modelPickerList.length) {
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
self.modelPickerList = (data.models || []).filter(function(m) { return m.available; });
|
||||
self.showModelPicker = true;
|
||||
self.modelPickerIdx = 0;
|
||||
}).catch(function() {});
|
||||
} else {
|
||||
self.showModelPicker = true;
|
||||
}
|
||||
} else if (val.startsWith('/')) {
|
||||
self.showModelPicker = false;
|
||||
self.slashFilter = val.slice(1).toLowerCase();
|
||||
self.showSlashMenu = true;
|
||||
self.slashIdx = 0;
|
||||
} else {
|
||||
self.showSlashMenu = false;
|
||||
self.showModelPicker = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
get filteredModelPicker() {
|
||||
if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15);
|
||||
var f = this.modelPickerFilter;
|
||||
return this.modelPickerList.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
}).slice(0, 15);
|
||||
},
|
||||
|
||||
pickModel(modelId) {
|
||||
this.showModelPicker = false;
|
||||
this.inputText = '/model ' + modelId;
|
||||
this.sendMessage();
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
|
||||
@@ -19,6 +19,8 @@ function skillsPage() {
|
||||
installingSlug: null,
|
||||
installResult: null,
|
||||
_searchTimer: null,
|
||||
_browseCache: {}, // { key: { ts, data } } client-side 60s cache
|
||||
_searchCache: {},
|
||||
|
||||
// Skill detail modal
|
||||
skillDetail: null,
|
||||
@@ -146,9 +148,16 @@ function skillsPage() {
|
||||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||||
},
|
||||
|
||||
// ClawHub browse by sort
|
||||
// ClawHub browse by sort (with 60s client-side cache)
|
||||
async browseClawHub(sort) {
|
||||
this.clawhubSort = sort || 'trending';
|
||||
var ckey = 'browse:' + this.clawhubSort;
|
||||
var cached = this._browseCache[ckey];
|
||||
if (cached && (Date.now() - cached.ts) < 60000) {
|
||||
this.clawhubBrowseResults = cached.data.items || [];
|
||||
this.clawhubNextCursor = cached.data.next_cursor || null;
|
||||
return;
|
||||
}
|
||||
this.clawhubLoading = true;
|
||||
this.clawhubError = '';
|
||||
this.clawhubNextCursor = null;
|
||||
@@ -157,6 +166,7 @@ function skillsPage() {
|
||||
this.clawhubBrowseResults = data.items || [];
|
||||
this.clawhubNextCursor = data.next_cursor || null;
|
||||
if (data.error) this.clawhubError = data.error;
|
||||
this._browseCache[ckey] = { ts: Date.now(), data: data };
|
||||
} catch(e) {
|
||||
this.clawhubBrowseResults = [];
|
||||
this.clawhubError = e.message || 'Browse failed';
|
||||
|
||||
@@ -37,6 +37,8 @@ function workflowBuilder() {
|
||||
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
// Load agents for the agent step dropdown
|
||||
@@ -50,6 +52,157 @@ function workflowBuilder() {
|
||||
self.addNode('start', 60, 200);
|
||||
},
|
||||
|
||||
// ── SVG Manual Rendering ────────────────────────────
|
||||
// Alpine.js x-for inside <svg> breaks because document.importNode
|
||||
// doesn't handle SVG namespace correctly. We render nodes/connections
|
||||
// manually via createElementNS and schedule re-renders reactively.
|
||||
|
||||
scheduleRender: function() {
|
||||
if (this._renderScheduled) return;
|
||||
this._renderScheduled = true;
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self._renderScheduled = false;
|
||||
self.renderCanvas();
|
||||
});
|
||||
},
|
||||
|
||||
renderCanvas: function() {
|
||||
var container = document.getElementById('wf-render-group');
|
||||
if (!container) return;
|
||||
var SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
var self = this;
|
||||
|
||||
// Clear previous rendered content
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
// ── Connections ──
|
||||
for (var ci = 0; ci < this.connections.length; ci++) {
|
||||
var conn = this.connections[ci];
|
||||
var d = this.getConnectionPath(conn);
|
||||
if (!d) continue;
|
||||
var path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke', (this.selectedConnection && this.selectedConnection.id === conn.id) ? 'var(--accent)' : 'var(--text-dim)');
|
||||
path.setAttribute('stroke-width', (this.selectedConnection && this.selectedConnection.id === conn.id) ? '3' : '2');
|
||||
path.style.cursor = 'pointer';
|
||||
(function(c) {
|
||||
path.addEventListener('click', function(e) { e.stopPropagation(); self.selectedConnection = c; self.scheduleRender(); });
|
||||
})(conn);
|
||||
container.appendChild(path);
|
||||
}
|
||||
|
||||
// ── Connection preview ──
|
||||
if (this.connecting && this.connectPreview) {
|
||||
var pd = this.getPreviewPath();
|
||||
if (pd) {
|
||||
var preview = document.createElementNS(SVG_NS, 'path');
|
||||
preview.setAttribute('d', pd);
|
||||
preview.setAttribute('fill', 'none');
|
||||
preview.setAttribute('stroke', 'var(--accent)');
|
||||
preview.setAttribute('stroke-width', '2');
|
||||
preview.setAttribute('stroke-dasharray', '6,3');
|
||||
container.appendChild(preview);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nodes ──
|
||||
for (var ni = 0; ni < this.nodes.length; ni++) {
|
||||
var node = this.nodes[ni];
|
||||
var g = document.createElementNS(SVG_NS, 'g');
|
||||
g.classList.add('wf-node');
|
||||
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
|
||||
(function(n) {
|
||||
g.addEventListener('mousedown', function(e) { self.onNodeMouseDown(n, e); });
|
||||
g.addEventListener('dblclick', function() { self.editNode(n); });
|
||||
})(node);
|
||||
|
||||
// Node body rect
|
||||
var rect = document.createElementNS(SVG_NS, 'rect');
|
||||
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
|
||||
rect.setAttribute('width', node.width); rect.setAttribute('height', node.height);
|
||||
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
|
||||
rect.setAttribute('fill', (self.selectedNode && self.selectedNode.id === node.id) ? 'var(--card-bg)' : 'var(--bg-secondary)');
|
||||
rect.setAttribute('stroke', (self.selectedNode && self.selectedNode.id === node.id) ? node.color : 'var(--border)');
|
||||
rect.setAttribute('stroke-width', '2');
|
||||
rect.style.cursor = 'grab';
|
||||
g.appendChild(rect);
|
||||
|
||||
// Color accent bar
|
||||
var bar = document.createElementNS(SVG_NS, 'rect');
|
||||
bar.setAttribute('x', '0'); bar.setAttribute('y', '0');
|
||||
bar.setAttribute('width', '6'); bar.setAttribute('height', node.height);
|
||||
bar.setAttribute('rx', '3'); bar.setAttribute('ry', '0');
|
||||
bar.setAttribute('fill', node.color);
|
||||
g.appendChild(bar);
|
||||
|
||||
// Icon circle + text
|
||||
var circle = document.createElementNS(SVG_NS, 'circle');
|
||||
circle.setAttribute('cx', '28'); circle.setAttribute('cy', node.height / 2);
|
||||
circle.setAttribute('r', '14'); circle.setAttribute('fill', node.color);
|
||||
circle.setAttribute('opacity', '0.15');
|
||||
g.appendChild(circle);
|
||||
|
||||
var iconText = document.createElementNS(SVG_NS, 'text');
|
||||
iconText.setAttribute('x', '28'); iconText.setAttribute('y', node.height / 2 + 4);
|
||||
iconText.setAttribute('text-anchor', 'middle'); iconText.setAttribute('fill', node.color);
|
||||
iconText.setAttribute('style', 'font-size:12px;font-weight:700;pointer-events:none');
|
||||
iconText.textContent = node.icon;
|
||||
g.appendChild(iconText);
|
||||
|
||||
// Label
|
||||
var label = document.createElementNS(SVG_NS, 'text');
|
||||
label.setAttribute('x', '50'); label.setAttribute('y', node.height / 2 - 4);
|
||||
label.setAttribute('fill', 'var(--text)');
|
||||
label.setAttribute('style', 'font-size:12px;font-weight:600;pointer-events:none');
|
||||
label.textContent = node.label;
|
||||
g.appendChild(label);
|
||||
|
||||
// Sub-label
|
||||
var subLabel = document.createElementNS(SVG_NS, 'text');
|
||||
subLabel.setAttribute('x', '50'); subLabel.setAttribute('y', node.height / 2 + 12);
|
||||
subLabel.setAttribute('fill', 'var(--text-dim)');
|
||||
subLabel.setAttribute('style', 'font-size:10px;pointer-events:none');
|
||||
if (node.type === 'agent') subLabel.textContent = node.config.agent_name || 'No agent';
|
||||
else if (node.type === 'condition') subLabel.textContent = node.config.expression || 'No condition';
|
||||
else if (node.type === 'loop') subLabel.textContent = 'max ' + (node.config.max_iterations || 5) + ' iters';
|
||||
else if (node.type === 'parallel') subLabel.textContent = (node.config.fan_count || 3) + ' branches';
|
||||
else if (node.type === 'collect') subLabel.textContent = node.config.strategy || 'all';
|
||||
g.appendChild(subLabel);
|
||||
|
||||
// Input ports
|
||||
for (var pi = 0; pi < node.ports.in; pi++) {
|
||||
var inp = document.createElementNS(SVG_NS, 'circle');
|
||||
inp.classList.add('wf-port', 'wf-port-in');
|
||||
inp.setAttribute('cx', node.width / (node.ports.in + 1) * (pi + 1));
|
||||
inp.setAttribute('cy', '0'); inp.setAttribute('r', '6');
|
||||
inp.setAttribute('fill', 'var(--bg-secondary)');
|
||||
inp.setAttribute('stroke', 'var(--text-dim)'); inp.setAttribute('stroke-width', '2');
|
||||
(function(nid, idx) {
|
||||
inp.addEventListener('mouseup', function(e) { e.stopPropagation(); self.endConnect(nid, idx, e); });
|
||||
})(node.id, pi);
|
||||
g.appendChild(inp);
|
||||
}
|
||||
|
||||
// Output ports
|
||||
for (var po = 0; po < node.ports.out; po++) {
|
||||
var outp = document.createElementNS(SVG_NS, 'circle');
|
||||
outp.classList.add('wf-port', 'wf-port-out');
|
||||
outp.setAttribute('cx', node.width / (node.ports.out + 1) * (po + 1));
|
||||
outp.setAttribute('cy', node.height); outp.setAttribute('r', '6');
|
||||
outp.setAttribute('fill', 'var(--bg-secondary)');
|
||||
outp.setAttribute('stroke', node.color); outp.setAttribute('stroke-width', '2');
|
||||
(function(nid, idx) {
|
||||
outp.addEventListener('mousedown', function(e) { e.stopPropagation(); self.startConnect(nid, idx, e); });
|
||||
})(node.id, po);
|
||||
g.appendChild(outp);
|
||||
}
|
||||
|
||||
container.appendChild(g);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Node Management ──────────────────────────────────
|
||||
|
||||
addNode: function(type, x, y) {
|
||||
@@ -83,6 +236,7 @@ function workflowBuilder() {
|
||||
node.config = { strategy: 'all' };
|
||||
}
|
||||
this.nodes.push(node);
|
||||
this.scheduleRender();
|
||||
return node;
|
||||
},
|
||||
|
||||
@@ -95,6 +249,7 @@ function workflowBuilder() {
|
||||
this.selectedNode = null;
|
||||
this.showNodeEditor = false;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
duplicateNode: function(node) {
|
||||
@@ -166,11 +321,13 @@ function workflowBuilder() {
|
||||
}
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
deleteConnection: function(connId) {
|
||||
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
|
||||
this.selectedConnection = null;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Drag Handling ────────────────────────────────────
|
||||
@@ -205,11 +362,13 @@ function workflowBuilder() {
|
||||
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
|
||||
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
|
||||
}
|
||||
this.scheduleRender();
|
||||
} else if (this.connecting) {
|
||||
this.connectPreview = {
|
||||
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
|
||||
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
|
||||
};
|
||||
this.scheduleRender();
|
||||
} else if (this.canvasDragging) {
|
||||
this.canvasOffset = {
|
||||
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
|
||||
@@ -223,6 +382,7 @@ function workflowBuilder() {
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.canvasDragging = false;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -386,7 +546,7 @@ function workflowBuilder() {
|
||||
var rect = this._getCanvasRect();
|
||||
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
|
||||
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
|
||||
this.addNode(type, x - 90, y - 35);
|
||||
this.addNode(type, x - 90, y - 35); // addNode already calls scheduleRender
|
||||
},
|
||||
|
||||
onCanvasDragOver: function(e) {
|
||||
@@ -405,6 +565,7 @@ function workflowBuilder() {
|
||||
this.nodes[i].y = y;
|
||||
y += 120;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Clear ────────────────────────────────────────────
|
||||
@@ -414,7 +575,7 @@ function workflowBuilder() {
|
||||
this.connections = [];
|
||||
this.selectedNode = null;
|
||||
this.nextId = 1;
|
||||
this.addNode('start', 60, 200);
|
||||
this.addNode('start', 60, 200); // addNode already calls scheduleRender
|
||||
},
|
||||
|
||||
// ── Zoom controls ────────────────────────────────────
|
||||
|
||||
@@ -76,6 +76,7 @@ async fn start_test_server_with_provider(
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -702,6 +703,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
});
|
||||
|
||||
let api_key_state = state.kernel.config.api_key.clone();
|
||||
|
||||
@@ -113,6 +113,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -236,6 +237,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -57,6 +57,7 @@ async fn start_test_server() -> TestServer {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -373,10 +373,15 @@ async fn dispatch_message(
|
||||
|
||||
// Fetch per-channel overrides (if configured)
|
||||
let overrides = handle.channel_overrides(ct_str).await;
|
||||
let channel_default_format = match ct_str {
|
||||
"telegram" => OutputFormat::TelegramHtml,
|
||||
"slack" => OutputFormat::SlackMrkdwn,
|
||||
_ => OutputFormat::Markdown,
|
||||
};
|
||||
let output_format = overrides
|
||||
.as_ref()
|
||||
.and_then(|o| o.output_format)
|
||||
.unwrap_or(OutputFormat::Markdown);
|
||||
.unwrap_or(channel_default_format);
|
||||
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
|
||||
let thread_id = if threading_enabled {
|
||||
message.thread_id.as_deref()
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct DiscordAdapter {
|
||||
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
|
||||
token: Zeroizing<String>,
|
||||
client: reqwest::Client,
|
||||
allowed_guilds: Vec<u64>,
|
||||
allowed_guilds: Vec<String>,
|
||||
intents: u64,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -51,7 +51,7 @@ pub struct DiscordAdapter {
|
||||
}
|
||||
|
||||
impl DiscordAdapter {
|
||||
pub fn new(token: String, allowed_guilds: Vec<u64>, intents: u64) -> Self {
|
||||
pub fn new(token: String, allowed_guilds: Vec<String>, intents: u64) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
@@ -422,7 +422,7 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
async fn parse_discord_message(
|
||||
d: &serde_json::Value,
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_guilds: &[u64],
|
||||
allowed_guilds: &[String],
|
||||
) -> Option<ChannelMessage> {
|
||||
let author = d.get("author")?;
|
||||
let author_id = author["id"].as_str()?;
|
||||
@@ -442,8 +442,7 @@ async fn parse_discord_message(
|
||||
// Filter by allowed guilds
|
||||
if !allowed_guilds.is_empty() {
|
||||
if let Some(guild_id) = d["guild_id"].as_str() {
|
||||
let gid: u64 = guild_id.parse().unwrap_or(0);
|
||||
if !allowed_guilds.contains(&gid) {
|
||||
if !allowed_guilds.iter().any(|g| g == guild_id) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -587,11 +586,11 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &[111, 222]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()]).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &[999]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()]).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -685,7 +684,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_discord_adapter_creation() {
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec![123, 456], 33280);
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], 37376);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ impl TelegramAdapter {
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": chunk,
|
||||
"parse_mode": "HTML",
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
@@ -97,6 +98,103 @@ impl TelegramAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendPhoto` on the Telegram API.
|
||||
async fn api_send_photo(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
photo_url: &str,
|
||||
caption: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendPhoto",
|
||||
self.token.as_str()
|
||||
);
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"photo": photo_url,
|
||||
});
|
||||
if let Some(cap) = caption {
|
||||
body["caption"] = serde_json::Value::String(cap.to_string());
|
||||
body["parse_mode"] = serde_json::Value::String("HTML".to_string());
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendPhoto failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendDocument` on the Telegram API.
|
||||
async fn api_send_document(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
document_url: &str,
|
||||
filename: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendDocument",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"document": document_url,
|
||||
"caption": filename,
|
||||
});
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendDocument failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendVoice` on the Telegram API.
|
||||
async fn api_send_voice(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
voice_url: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendVoice",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"voice": voice_url,
|
||||
});
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendVoice failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendLocation` on the Telegram API.
|
||||
async fn api_send_location(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendLocation",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
});
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendLocation failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendChatAction` to show "typing..." indicator.
|
||||
async fn api_send_typing(&self, chat_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
@@ -130,6 +228,26 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
let bot_name = self.validate_token().await?;
|
||||
info!("Telegram bot @{bot_name} connected");
|
||||
|
||||
// Clear any existing webhook to avoid 409 Conflict during getUpdates polling.
|
||||
// This is necessary when the daemon restarts — the old polling session may
|
||||
// still be active on Telegram's side for ~30s, causing 409 errors.
|
||||
{
|
||||
let delete_url = format!(
|
||||
"https://api.telegram.org/bot{}/deleteWebhook",
|
||||
self.token.as_str()
|
||||
);
|
||||
match self
|
||||
.client
|
||||
.post(&delete_url)
|
||||
.json(&serde_json::json!({"drop_pending_updates": true}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => info!("Telegram: cleared webhook, polling mode active"),
|
||||
Err(e) => tracing::warn!("Telegram: deleteWebhook failed (non-fatal): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
|
||||
let token = self.token.clone();
|
||||
@@ -285,10 +403,23 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(chat_id, &text).await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(chat_id, "(Unsupported content type)")
|
||||
ChannelContent::Image { url, caption } => {
|
||||
self.api_send_photo(chat_id, &url, caption.as_deref())
|
||||
.await?;
|
||||
}
|
||||
ChannelContent::File { url, filename } => {
|
||||
self.api_send_document(chat_id, &url, &filename).await?;
|
||||
}
|
||||
ChannelContent::Voice { url, .. } => {
|
||||
self.api_send_voice(chat_id, &url).await?;
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
self.api_send_location(chat_id, lat, lon).await?;
|
||||
}
|
||||
ChannelContent::Command { name, args } => {
|
||||
let text = format!("/{name} {}", args.join(" "));
|
||||
self.api_send_message(chat_id, text.trim()).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ const AFTER_HELP: &str = "\
|
||||
openfang doctor Run diagnostic health checks
|
||||
openfang channel setup Interactive channel setup wizard
|
||||
openfang cron list List scheduled jobs
|
||||
openfang uninstall Completely remove OpenFang from your system
|
||||
|
||||
\x1b[1;36mQuick Start:\x1b[0m
|
||||
1. openfang init Set up config + API key
|
||||
@@ -279,6 +280,15 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
confirm: bool,
|
||||
},
|
||||
/// Completely uninstall OpenFang from your system.
|
||||
Uninstall {
|
||||
/// Skip confirmation prompt (also --yes).
|
||||
#[arg(long, alias = "yes")]
|
||||
confirm: bool,
|
||||
/// Keep config files (config.toml, .env, secrets.env).
|
||||
#[arg(long)]
|
||||
keep_config: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -395,6 +405,26 @@ enum HandCommands {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Check dependency status for a hand.
|
||||
CheckDeps {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Install missing dependencies for a hand.
|
||||
InstallDeps {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Pause a running hand instance.
|
||||
Pause {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
/// Resume a paused hand instance.
|
||||
Resume {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -465,6 +495,15 @@ enum AgentCommands {
|
||||
/// Agent ID (UUID).
|
||||
agent_id: String,
|
||||
},
|
||||
/// Set an agent property (e.g., model).
|
||||
Set {
|
||||
/// Agent ID (UUID).
|
||||
agent_id: String,
|
||||
/// Field to set (model).
|
||||
field: String,
|
||||
/// New value.
|
||||
value: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -593,6 +632,9 @@ enum CronCommands {
|
||||
spec: String,
|
||||
/// Prompt to send when the job fires.
|
||||
prompt: String,
|
||||
/// Optional job name (auto-generated if omitted).
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Delete a scheduled job.
|
||||
Delete {
|
||||
@@ -829,6 +871,11 @@ fn main() {
|
||||
AgentCommands::List { json } => cmd_agent_list(cli.config, json),
|
||||
AgentCommands::Chat { agent_id } => cmd_agent_chat(cli.config, &agent_id),
|
||||
AgentCommands::Kill { agent_id } => cmd_agent_kill(cli.config, &agent_id),
|
||||
AgentCommands::Set {
|
||||
agent_id,
|
||||
field,
|
||||
value,
|
||||
} => cmd_agent_set(&agent_id, &field, &value),
|
||||
},
|
||||
Some(Commands::Workflow(sub)) => match sub {
|
||||
WorkflowCommands::List => cmd_workflow_list(),
|
||||
@@ -866,6 +913,10 @@ fn main() {
|
||||
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
||||
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
||||
HandCommands::Info { id } => cmd_hand_info(&id),
|
||||
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
|
||||
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
|
||||
HandCommands::Pause { id } => cmd_hand_pause(&id),
|
||||
HandCommands::Resume { id } => cmd_hand_resume(&id),
|
||||
},
|
||||
Some(Commands::Config(sub)) => match sub {
|
||||
ConfigCommands::Show => cmd_config_show(),
|
||||
@@ -916,7 +967,8 @@ fn main() {
|
||||
agent,
|
||||
spec,
|
||||
prompt,
|
||||
} => cmd_cron_create(&agent, &spec, &prompt),
|
||||
name,
|
||||
} => cmd_cron_create(&agent, &spec, &prompt, name.as_deref()),
|
||||
CronCommands::Delete { id } => cmd_cron_delete(&id),
|
||||
CronCommands::Enable { id } => cmd_cron_toggle(&id, true),
|
||||
CronCommands::Disable { id } => cmd_cron_toggle(&id, false),
|
||||
@@ -955,6 +1007,7 @@ fn main() {
|
||||
SystemCommands::Version { json } => cmd_system_version(json),
|
||||
},
|
||||
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
|
||||
Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1617,6 +1670,38 @@ fn cmd_agent_kill(config: Option<PathBuf>, agent_id_str: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_agent_set(agent_id_str: &str, field: &str, value: &str) {
|
||||
match field {
|
||||
"model" => {
|
||||
if let Some(base) = find_daemon() {
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.put(format!("{base}/api/agents/{agent_id_str}/model"))
|
||||
.json(&serde_json::json!({"model": value}))
|
||||
.send(),
|
||||
);
|
||||
if body.get("status").is_some() {
|
||||
println!("Agent {agent_id_str} model set to {value}.");
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to set model: {}",
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
eprintln!("No running daemon found. Start one with: openfang start");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
eprintln!("Unknown field: {field}. Supported fields: model");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_agent_new(config: Option<PathBuf>, template_name: Option<String>) {
|
||||
let all_templates = templates::load_all_templates();
|
||||
if all_templates.is_empty() {
|
||||
@@ -3895,6 +3980,87 @@ fn cmd_hand_info(id: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
fn cmd_hand_check_deps(id: &str) {
|
||||
let base = require_daemon("hand check-deps");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/check-deps"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_install_deps(id: &str) {
|
||||
let base = require_daemon("hand install-deps");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/install-deps"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Dependencies installed for hand '{id}'."));
|
||||
if let Some(results) = body.get("results") {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(results).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_pause(id: &str) {
|
||||
let base = require_daemon("hand pause");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/instances/{id}/pause"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Hand instance '{id}' paused."));
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_resume(id: &str) {
|
||||
let base = require_daemon("hand resume");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/instances/{id}/resume"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Hand instance '{id}' resumed."));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / API key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4163,6 +4329,31 @@ fn cmd_config_set(key: &str, value: &str) {
|
||||
}
|
||||
|
||||
let last_key = parts[parts.len() - 1];
|
||||
|
||||
// Validate: single-part keys must be known scalar fields, not sections.
|
||||
// Writing a section name as a scalar silently breaks config deserialization.
|
||||
if parts.len() == 1 {
|
||||
let known_scalars = [
|
||||
"home_dir",
|
||||
"data_dir",
|
||||
"log_level",
|
||||
"api_listen",
|
||||
"network_enabled",
|
||||
"api_key",
|
||||
"language",
|
||||
"max_cron_jobs",
|
||||
"usage_footer",
|
||||
"workspaces_dir",
|
||||
];
|
||||
if !known_scalars.contains(&last_key) {
|
||||
ui::error_with_fix(
|
||||
&format!("'{last_key}' is a section, not a scalar"),
|
||||
&format!("Use dotted notation: {last_key}.field_name"),
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let tbl = current.as_table_mut().unwrap_or_else(|| {
|
||||
ui::error(&format!("Parent of '{key}' is not a table"));
|
||||
std::process::exit(1);
|
||||
@@ -5067,16 +5258,40 @@ fn cmd_cron_list(json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str) {
|
||||
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<&str>) {
|
||||
let base = require_daemon("cron create");
|
||||
let client = daemon_client();
|
||||
|
||||
// Use explicit name if provided, otherwise derive from agent + prompt
|
||||
let name = if let Some(n) = explicit_name {
|
||||
n.to_string()
|
||||
} else {
|
||||
let short_prompt: String = prompt
|
||||
.split_whitespace()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.take(64)
|
||||
.collect();
|
||||
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
|
||||
};
|
||||
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/cron/jobs"))
|
||||
.json(&serde_json::json!({
|
||||
"agent_id": agent,
|
||||
"cron_expr": spec,
|
||||
"prompt": prompt,
|
||||
"name": name,
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": spec
|
||||
},
|
||||
"action": {
|
||||
"kind": "agent_turn",
|
||||
"message": prompt
|
||||
}
|
||||
}))
|
||||
.send(),
|
||||
);
|
||||
@@ -5731,6 +5946,358 @@ fn cmd_reset(confirm: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uninstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 exe_path = std::env::current_exe().ok();
|
||||
|
||||
// Step 1: Show what will be removed
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"This will completely uninstall OpenFang from your system."
|
||||
.bold()
|
||||
.red()
|
||||
);
|
||||
println!();
|
||||
if openfang_dir.exists() {
|
||||
if keep_config {
|
||||
println!(
|
||||
" • Remove data in {} (keeping config files)",
|
||||
openfang_dir.display()
|
||||
);
|
||||
} else {
|
||||
println!(" • Remove {}", openfang_dir.display());
|
||||
}
|
||||
}
|
||||
if let Some(ref exe) = exe_path {
|
||||
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"
|
||||
});
|
||||
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
||||
println!(" • Remove cargo binary: {}", cargo_bin.display());
|
||||
}
|
||||
println!(" • Remove auto-start entries (if any)");
|
||||
println!(" • Clean PATH from shell configs (if any)");
|
||||
println!();
|
||||
|
||||
// Step 2: Confirm
|
||||
if !confirm {
|
||||
let answer = prompt_input(" Type 'uninstall' to confirm: ");
|
||||
if answer.trim() != "uninstall" {
|
||||
println!(" Cancelled.");
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// Step 3: Stop running daemon
|
||||
if find_daemon().is_some() {
|
||||
println!(" Stopping running daemon...");
|
||||
cmd_stop();
|
||||
// Give it a moment
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
// Force kill if still alive
|
||||
if find_daemon().is_some() {
|
||||
if let Some(info) = read_daemon_info(&openfang_dir) {
|
||||
force_kill_pid(info.pid);
|
||||
let _ = std::fs::remove_file(openfang_dir.join("daemon.json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Remove auto-start entries
|
||||
remove_autostart_entries(&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());
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Remove ~/.openfang/ data
|
||||
if openfang_dir.exists() {
|
||||
if keep_config {
|
||||
remove_dir_except_config(&openfang_dir);
|
||||
ui::success("Removed data (kept config files)");
|
||||
} else {
|
||||
match std::fs::remove_dir_all(&openfang_dir) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove {}: {e}",
|
||||
openfang_dir.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Remove cargo bin copy if it exists and is separate from current exe
|
||||
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
||||
match std::fs::remove_file(&cargo_bin) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove {}: {e}",
|
||||
cargo_bin.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Remove the binary itself (must be last)
|
||||
if let Some(exe) = exe_path {
|
||||
remove_self_binary(&exe);
|
||||
}
|
||||
|
||||
println!();
|
||||
ui::success("OpenFang has been uninstalled. Goodbye!");
|
||||
}
|
||||
|
||||
/// Remove auto-start / launch-agent / systemd entries.
|
||||
#[allow(unused_variables)]
|
||||
fn remove_autostart_entries(home: &std::path::Path) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows: remove from HKCU\Software\Microsoft\Windows\CurrentVersion\Run
|
||||
let output = std::process::Command::new("reg")
|
||||
.args([
|
||||
"delete",
|
||||
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
"/v",
|
||||
"OpenFang",
|
||||
"/f",
|
||||
])
|
||||
.output();
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
ui::success("Removed Windows auto-start registry entry");
|
||||
}
|
||||
_ => {} // Entry didn't exist — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let plist = home.join("Library/LaunchAgents/ai.openfang.desktop.plist");
|
||||
if plist.exists() {
|
||||
// Unload first
|
||||
let _ = std::process::Command::new("launchctl")
|
||||
.args(["unload", &plist.to_string_lossy()])
|
||||
.output();
|
||||
match std::fs::remove_file(&plist) {
|
||||
Ok(()) => ui::success("Removed macOS launch agent"),
|
||||
Err(e) => ui::error(&format!("Failed to remove launch agent: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let desktop_file = home.join(".config/autostart/OpenFang.desktop");
|
||||
if desktop_file.exists() {
|
||||
match std::fs::remove_file(&desktop_file) {
|
||||
Ok(()) => ui::success("Removed Linux autostart entry"),
|
||||
Err(e) => ui::error(&format!("Failed to remove autostart entry: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for systemd user service
|
||||
let service_file = home.join(".config/systemd/user/openfang.service");
|
||||
if service_file.exists() {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "disable", "--now", "openfang.service"])
|
||||
.output();
|
||||
match std::fs::remove_file(&service_file) {
|
||||
Ok(()) => {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "daemon-reload"])
|
||||
.output();
|
||||
ui::success("Removed systemd user service");
|
||||
}
|
||||
Err(e) => ui::error(&format!("Failed to remove systemd service: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove lines from shell config files that add openfang to PATH.
|
||||
#[allow(unused_variables)]
|
||||
fn clean_path_entries(home: &std::path::Path, openfang_dir: &str) {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let shell_files = [
|
||||
home.join(".bashrc"),
|
||||
home.join(".bash_profile"),
|
||||
home.join(".profile"),
|
||||
home.join(".zshrc"),
|
||||
home.join(".config/fish/config.fish"),
|
||||
];
|
||||
|
||||
for path in &shell_files {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::fs::read_to_string(path) else {
|
||||
continue;
|
||||
};
|
||||
let filtered: Vec<&str> = content
|
||||
.lines()
|
||||
.filter(|line| !is_openfang_path_line(line, openfang_dir))
|
||||
.collect();
|
||||
if filtered.len() < content.lines().count() {
|
||||
let new_content = filtered.join("\n");
|
||||
// Preserve trailing newline if original had one
|
||||
let new_content = if content.ends_with('\n') {
|
||||
format!("{new_content}\n")
|
||||
} else {
|
||||
new_content
|
||||
};
|
||||
if std::fs::write(path, &new_content).is_ok() {
|
||||
ui::success(&format!("Cleaned PATH from {}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Read User PATH via PowerShell, filter out openfang entries, write back
|
||||
let output = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Environment]::GetEnvironmentVariable('PATH', 'User')",
|
||||
])
|
||||
.output();
|
||||
if let Ok(out) = output {
|
||||
if out.status.success() {
|
||||
let current = String::from_utf8_lossy(&out.stdout);
|
||||
let current = current.trim();
|
||||
if !current.is_empty() {
|
||||
let dir_lower = openfang_dir.to_lowercase();
|
||||
let filtered: Vec<&str> = current
|
||||
.split(';')
|
||||
.filter(|entry| {
|
||||
let e = entry.trim().to_lowercase();
|
||||
!e.is_empty() && !e.contains("openfang") && !e.contains(&dir_lower)
|
||||
})
|
||||
.collect();
|
||||
if filtered.len() < current.split(';').count() {
|
||||
let new_path = filtered.join(";");
|
||||
let ps_cmd = format!(
|
||||
"[Environment]::SetEnvironmentVariable('PATH', '{}', 'User')",
|
||||
new_path.replace('\'', "''")
|
||||
);
|
||||
let result = std::process::Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &ps_cmd])
|
||||
.output();
|
||||
if result.is_ok_and(|o| o.status.success()) {
|
||||
ui::success("Cleaned PATH from Windows user environment");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if a shell config line is an openfang PATH export.
|
||||
/// Must match BOTH an openfang reference AND a PATH-setting pattern.
|
||||
#[cfg(any(not(windows), test))]
|
||||
fn is_openfang_path_line(line: &str, openfang_dir: &str) -> bool {
|
||||
let lower = line.to_lowercase();
|
||||
let has_openfang = lower.contains("openfang") || lower.contains(&openfang_dir.to_lowercase());
|
||||
if !has_openfang {
|
||||
return false;
|
||||
}
|
||||
// Match common PATH-setting patterns
|
||||
lower.contains("export path=")
|
||||
|| lower.contains("export path =")
|
||||
|| lower.starts_with("path=")
|
||||
|| lower.contains("set -gx path")
|
||||
|| lower.contains("fish_add_path")
|
||||
}
|
||||
|
||||
/// Remove everything in ~/.openfang/ except config files.
|
||||
fn remove_dir_except_config(openfang_dir: &std::path::Path) {
|
||||
let keep = ["config.toml", ".env", "secrets.env"];
|
||||
let Ok(entries) = std::fs::read_dir(openfang_dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if keep.contains(&name_str.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the currently-running binary.
|
||||
fn remove_self_binary(exe_path: &std::path::Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// On Unix, running binaries can be unlinked — the OS keeps the inode
|
||||
// alive until the process exits.
|
||||
match std::fs::remove_file(exe_path) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", exe_path.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove binary {}: {e}",
|
||||
exe_path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows locks running executables. Rename first, then spawn a
|
||||
// detached process that waits briefly and deletes the renamed file.
|
||||
let old_path = exe_path.with_extension("exe.old");
|
||||
if std::fs::rename(exe_path, &old_path).is_err() {
|
||||
ui::error(&format!(
|
||||
"Could not rename binary for deferred deletion: {}",
|
||||
exe_path.display()
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
|
||||
let del_cmd = format!(
|
||||
"ping -n 3 127.0.0.1 >nul & del /f /q \"{}\"",
|
||||
old_path.display()
|
||||
);
|
||||
let _ = std::process::Command::new("cmd.exe")
|
||||
.args(["/C", &del_cmd])
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.spawn();
|
||||
|
||||
ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -5855,4 +6422,44 @@ args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
];
|
||||
assert_eq!(events.len(), 4);
|
||||
}
|
||||
|
||||
// --- Uninstall command unit tests ---
|
||||
|
||||
#[test]
|
||||
fn test_uninstall_path_line_filter() {
|
||||
use super::is_openfang_path_line;
|
||||
let dir = "/home/user/.openfang/bin";
|
||||
|
||||
// Should match: openfang PATH exports
|
||||
assert!(is_openfang_path_line(
|
||||
r#"export PATH="$HOME/.openfang/bin:$PATH""#,
|
||||
dir
|
||||
));
|
||||
assert!(is_openfang_path_line(
|
||||
r#"export PATH="/home/user/.openfang/bin:$PATH""#,
|
||||
dir
|
||||
));
|
||||
assert!(is_openfang_path_line(
|
||||
"set -gx PATH $HOME/.openfang/bin $PATH",
|
||||
dir
|
||||
));
|
||||
assert!(is_openfang_path_line(
|
||||
"fish_add_path $HOME/.openfang/bin",
|
||||
dir
|
||||
));
|
||||
|
||||
// Should NOT match: unrelated PATH exports
|
||||
assert!(!is_openfang_path_line(
|
||||
r#"export PATH="$HOME/.cargo/bin:$PATH""#,
|
||||
dir
|
||||
));
|
||||
assert!(!is_openfang_path_line(
|
||||
r#"export PATH="/usr/local/bin:$PATH""#,
|
||||
dir
|
||||
));
|
||||
|
||||
// Should NOT match: openfang lines that aren't PATH-related
|
||||
assert!(!is_openfang_path_line("# openfang config", dir));
|
||||
assert!(!is_openfang_path_line("alias of=openfang", dir));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,7 +1492,13 @@ pub fn spawn_browse_clawhub(backend: BackendRef, sort: String, tx: mpsc::Sender<
|
||||
}
|
||||
|
||||
fn parse_clawhub_results(body: &serde_json::Value) -> Vec<ClawHubResult> {
|
||||
body.as_array()
|
||||
// API returns {"items": [...]} wrapper, fall back to bare array for compat
|
||||
let items = body
|
||||
.get("items")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array());
|
||||
|
||||
items
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|r| ClawHubResult {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! **No-op** (informational only): log_level, language, mode.
|
||||
//!
|
||||
//! **Restart required**: api_listen, api_key, network, memory, default_model.
|
||||
//! **Restart required**: api_listen, api_key, network, memory.
|
||||
|
||||
use openfang_types::config::{KernelConfig, ReloadMode};
|
||||
use tracing::{info, warn};
|
||||
@@ -43,6 +43,8 @@ pub enum HotAction {
|
||||
ReloadFallbackProviders,
|
||||
/// Provider base URL overrides changed.
|
||||
ReloadProviderUrls,
|
||||
/// Default model changed — update in-place without restart.
|
||||
UpdateDefaultModel,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -161,11 +163,9 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
|
||||
.push("memory config changed".to_string());
|
||||
}
|
||||
|
||||
// Default model (driver needs recreation)
|
||||
// Default model — hot-reloadable (just swap config fields, new agents pick it up)
|
||||
if field_changed(&old.default_model, &new.default_model) {
|
||||
plan.restart_required = true;
|
||||
plan.restart_reasons
|
||||
.push("default_model changed".to_string());
|
||||
plan.hot_actions.push(HotAction::UpdateDefaultModel);
|
||||
}
|
||||
|
||||
// Home/data directory changes
|
||||
@@ -406,16 +406,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_model_requires_restart() {
|
||||
fn test_default_model_hot_reloadable() {
|
||||
let a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
b.default_model.model = "gpt-4".to_string();
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(plan.restart_required);
|
||||
assert!(plan
|
||||
.restart_reasons
|
||||
.iter()
|
||||
.any(|r| r.contains("default_model")));
|
||||
assert!(!plan.restart_required, "default_model should be hot-reloadable");
|
||||
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -131,6 +131,8 @@ pub struct OpenFangKernel {
|
||||
pub whatsapp_gateway_pid: Arc<std::sync::Mutex<Option<u32>>>,
|
||||
/// Channel adapters registered at bridge startup (for proactive `channel_send` tool).
|
||||
pub channel_adapters: dashmap::DashMap<String, Arc<dyn openfang_channels::types::ChannelAdapter>>,
|
||||
/// Hot-reloadable default model override (set via config hot-reload, read at agent spawn).
|
||||
pub default_model_override: std::sync::RwLock<Option<openfang_types::config::DefaultModelConfig>>,
|
||||
/// Weak self-reference for trigger dispatch (set after Arc wrapping).
|
||||
self_handle: OnceLock<Weak<OpenFangKernel>>,
|
||||
}
|
||||
@@ -530,7 +532,11 @@ impl OpenFangKernel {
|
||||
let driver_config = DriverConfig {
|
||||
provider: config.default_model.provider.clone(),
|
||||
api_key: std::env::var(&config.default_model.api_key_env).ok(),
|
||||
base_url: config.default_model.base_url.clone(),
|
||||
base_url: config
|
||||
.default_model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()),
|
||||
};
|
||||
let primary_driver = drivers::create_driver(&driver_config)
|
||||
.map_err(|e| KernelError::BootFailed(format!("LLM driver init failed: {e}")))?;
|
||||
@@ -546,7 +552,10 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
std::env::var(&fb.api_key_env).ok()
|
||||
},
|
||||
base_url: fb.base_url.clone(),
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| config.provider_urls.get(&fb.provider).cloned()),
|
||||
};
|
||||
match drivers::create_driver(&fb_config) {
|
||||
Ok(d) => {
|
||||
@@ -883,6 +892,7 @@ impl OpenFangKernel {
|
||||
booted_at: std::time::Instant::now(),
|
||||
whatsapp_gateway_pid: Arc::new(std::sync::Mutex::new(None)),
|
||||
channel_adapters: dashmap::DashMap::new(),
|
||||
default_model_override: std::sync::RwLock::new(None),
|
||||
self_handle: OnceLock::new(),
|
||||
};
|
||||
|
||||
@@ -894,6 +904,61 @@ impl OpenFangKernel {
|
||||
let agent_id = entry.id;
|
||||
let name = entry.name.clone();
|
||||
|
||||
// Check if TOML on disk is newer/different — if so, update from file
|
||||
let mut entry = entry;
|
||||
let toml_path = kernel
|
||||
.config
|
||||
.home_dir
|
||||
.join("agents")
|
||||
.join(&name)
|
||||
.join("agent.toml");
|
||||
if toml_path.exists() {
|
||||
match std::fs::read_to_string(&toml_path) {
|
||||
Ok(toml_str) => {
|
||||
match toml::from_str::<openfang_types::agent::AgentManifest>(
|
||||
&toml_str,
|
||||
) {
|
||||
Ok(disk_manifest) => {
|
||||
// Compare key fields to detect changes
|
||||
let changed = disk_manifest.name != entry.manifest.name
|
||||
|| disk_manifest.description != entry.manifest.description
|
||||
|| disk_manifest.model.system_prompt != entry.manifest.model.system_prompt
|
||||
|| disk_manifest.model.provider != entry.manifest.model.provider
|
||||
|| disk_manifest.model.model != entry.manifest.model.model
|
||||
|| disk_manifest.capabilities.tools != entry.manifest.capabilities.tools;
|
||||
if changed {
|
||||
info!(
|
||||
agent = %name,
|
||||
"Agent TOML on disk differs from DB, updating"
|
||||
);
|
||||
entry.manifest = disk_manifest;
|
||||
// Persist the update back to DB
|
||||
if let Err(e) = kernel.memory.save_agent(&entry) {
|
||||
warn!(
|
||||
agent = %name,
|
||||
"Failed to persist TOML update: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
agent = %name,
|
||||
path = %toml_path.display(),
|
||||
"Invalid agent TOML on disk, using DB version: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
agent = %name,
|
||||
"Failed to read agent TOML: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-grant capabilities
|
||||
let caps = manifest_to_capabilities(&entry.manifest);
|
||||
kernel.capabilities.grant(agent_id, caps);
|
||||
@@ -912,6 +977,33 @@ impl OpenFangKernel {
|
||||
restored_entry.manifest.exec_policy =
|
||||
Some(kernel.config.exec_policy.clone());
|
||||
}
|
||||
|
||||
// Apply global budget defaults to restored agents
|
||||
apply_budget_defaults(
|
||||
&kernel.config.budget,
|
||||
&mut restored_entry.manifest.resources,
|
||||
);
|
||||
|
||||
// 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();
|
||||
let is_default_model = restored_entry.manifest.model.model.is_empty();
|
||||
if is_default_provider && is_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 let Err(e) = kernel.registry.register(restored_entry) {
|
||||
tracing::warn!(agent = %name, "Failed to restore agent: {e}");
|
||||
} else {
|
||||
@@ -973,29 +1065,47 @@ impl OpenFangKernel {
|
||||
if manifest.exec_policy.is_none() {
|
||||
manifest.exec_policy = Some(self.config.exec_policy.clone());
|
||||
}
|
||||
info!(agent = %name, id = %agent_id, exec_mode = ?manifest.exec_policy.as_ref().map(|p| &p.mode), "Agent exec_policy resolved");
|
||||
|
||||
// Overlay kernel default_model onto agent if no custom key/url is set.
|
||||
// This ensures agents respect the user's configured provider from `openfang init`.
|
||||
// Overlay kernel default_model onto agent if agent didn't explicitly choose.
|
||||
// Only override when the agent has empty (unset) provider/model fields.
|
||||
// This preserves explicit model choices like provider="groq", model="llama-3.3-70b".
|
||||
if manifest.model.api_key_env.is_none() && manifest.model.base_url.is_none() {
|
||||
let dm = &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();
|
||||
// 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);
|
||||
let is_default_provider = manifest.model.provider.is_empty();
|
||||
let is_default_model = manifest.model.model.is_empty();
|
||||
if is_default_provider && is_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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create workspace directory for the agent
|
||||
// Normalize: strip provider prefix from model name if present
|
||||
let normalized = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
|
||||
if normalized != manifest.model.model {
|
||||
manifest.model.model = normalized;
|
||||
}
|
||||
|
||||
// Apply global budget defaults to agent resource quotas
|
||||
apply_budget_defaults(&self.config.budget, &mut manifest.resources);
|
||||
|
||||
// Create workspace directory for the agent (name-based, so SOUL.md survives recreation)
|
||||
let workspace_dir = manifest.workspace.clone().unwrap_or_else(|| {
|
||||
self.config.effective_workspaces_dir().join(format!(
|
||||
"{}-{}",
|
||||
&name,
|
||||
&agent_id.0.to_string()[..8]
|
||||
))
|
||||
self.config.effective_workspaces_dir().join(&name)
|
||||
});
|
||||
ensure_workspace(&workspace_dir)?;
|
||||
if manifest.generate_identity_files {
|
||||
@@ -1319,11 +1429,7 @@ impl OpenFangKernel {
|
||||
|
||||
// Lazy backfill: create workspace for existing agents spawned before workspaces
|
||||
if manifest.workspace.is_none() {
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(format!(
|
||||
"{}-{}",
|
||||
&manifest.name,
|
||||
&agent_id.0.to_string()[..8]
|
||||
));
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
|
||||
if let Err(e) = ensure_workspace(&workspace_dir) {
|
||||
warn!(agent_id = %agent_id, "Failed to backfill workspace (streaming): {e}");
|
||||
} else {
|
||||
@@ -1345,6 +1451,19 @@ impl OpenFangKernel {
|
||||
.flatten()
|
||||
.and_then(|v| v.as_str().map(String::from));
|
||||
|
||||
let peer_agents: Vec<(String, String, String)> = self
|
||||
.registry
|
||||
.list()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
(
|
||||
a.name.clone(),
|
||||
format!("{:?}", a.state),
|
||||
a.manifest.model.model.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let prompt_ctx = openfang_runtime::prompt_builder::PromptContext {
|
||||
agent_name: manifest.name.clone(),
|
||||
agent_description: manifest.description.clone(),
|
||||
@@ -1409,6 +1528,8 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
peer_agents,
|
||||
current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()),
|
||||
};
|
||||
manifest.model.system_prompt =
|
||||
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
|
||||
@@ -1776,11 +1897,7 @@ impl OpenFangKernel {
|
||||
|
||||
// Lazy backfill: create workspace for existing agents spawned before workspaces
|
||||
if manifest.workspace.is_none() {
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(format!(
|
||||
"{}-{}",
|
||||
&manifest.name,
|
||||
&agent_id.0.to_string()[..8]
|
||||
));
|
||||
let workspace_dir = self.config.effective_workspaces_dir().join(&manifest.name);
|
||||
if let Err(e) = ensure_workspace(&workspace_dir) {
|
||||
warn!(agent_id = %agent_id, "Failed to backfill workspace: {e}");
|
||||
} else {
|
||||
@@ -1803,6 +1920,19 @@ impl OpenFangKernel {
|
||||
.flatten()
|
||||
.and_then(|v| v.as_str().map(String::from));
|
||||
|
||||
let peer_agents: Vec<(String, String, String)> = self
|
||||
.registry
|
||||
.list()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
(
|
||||
a.name.clone(),
|
||||
format!("{:?}", a.state),
|
||||
a.manifest.model.model.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let prompt_ctx = openfang_runtime::prompt_builder::PromptContext {
|
||||
agent_name: manifest.name.clone(),
|
||||
agent_description: manifest.description.clone(),
|
||||
@@ -1867,6 +1997,8 @@ impl OpenFangKernel {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
peer_agents,
|
||||
current_date: Some(chrono::Local::now().format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)").to_string()),
|
||||
};
|
||||
manifest.model.system_prompt =
|
||||
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
|
||||
@@ -2086,6 +2218,35 @@ impl OpenFangKernel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear ALL conversation history for an agent (sessions + canonical).
|
||||
///
|
||||
/// Creates a fresh empty session afterward so the agent is still usable.
|
||||
pub fn clear_agent_history(&self, agent_id: AgentId) -> KernelResult<()> {
|
||||
let _entry = self.registry.get(agent_id).ok_or_else(|| {
|
||||
KernelError::OpenFang(OpenFangError::AgentNotFound(agent_id.to_string()))
|
||||
})?;
|
||||
|
||||
// Delete all regular sessions
|
||||
let _ = self.memory.delete_agent_sessions(agent_id);
|
||||
|
||||
// Delete canonical (cross-channel) session
|
||||
let _ = self.memory.delete_canonical_session(agent_id);
|
||||
|
||||
// Create a fresh session
|
||||
let new_session = self
|
||||
.memory
|
||||
.create_session(agent_id)
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
// Update registry with new session ID
|
||||
self.registry
|
||||
.update_session_id(agent_id, new_session.id)
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
info!(agent_id = %agent_id, "All agent history cleared");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all sessions for a specific agent.
|
||||
pub fn list_agent_sessions(&self, agent_id: AgentId) -> KernelResult<Vec<serde_json::Value>> {
|
||||
// Verify agent exists
|
||||
@@ -2265,16 +2426,23 @@ impl OpenFangKernel {
|
||||
// If catalog lookup failed, try to infer provider from model name prefix
|
||||
let provider = resolved_provider.or_else(|| infer_provider_from_model(model));
|
||||
|
||||
// Strip the provider prefix from the model name (e.g. "openrouter/deepseek/deepseek-chat" → "deepseek/deepseek-chat")
|
||||
let normalized_model = if let Some(ref prov) = provider {
|
||||
strip_provider_prefix(model, prov)
|
||||
} else {
|
||||
model.to_string()
|
||||
};
|
||||
|
||||
if let Some(provider) = provider {
|
||||
self.registry
|
||||
.update_model_and_provider(agent_id, model.to_string(), provider.clone())
|
||||
.update_model_and_provider(agent_id, normalized_model.clone(), provider.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
info!(agent_id = %agent_id, model = %model, provider = %provider, "Agent model+provider updated");
|
||||
info!(agent_id = %agent_id, model = %normalized_model, provider = %provider, "Agent model+provider updated");
|
||||
} else {
|
||||
self.registry
|
||||
.update_model(agent_id, model.to_string())
|
||||
.update_model(agent_id, normalized_model.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
info!(agent_id = %agent_id, model = %model, "Agent model updated (provider unchanged)");
|
||||
info!(agent_id = %agent_id, model = %normalized_model, "Agent model updated (provider unchanged)");
|
||||
}
|
||||
|
||||
// Persist the updated entry
|
||||
@@ -2282,6 +2450,10 @@ impl OpenFangKernel {
|
||||
let _ = self.memory.save_agent(&entry);
|
||||
}
|
||||
|
||||
// Clear canonical session to prevent memory poisoning from old model's responses
|
||||
let _ = self.memory.delete_canonical_session(agent_id);
|
||||
debug!(agent_id = %agent_id, "Cleared canonical session after model switch");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2354,6 +2526,30 @@ impl OpenFangKernel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's tool allowlist and/or blocklist.
|
||||
pub fn set_agent_tool_filters(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
allowlist: Option<Vec<String>>,
|
||||
blocklist: Option<Vec<String>>,
|
||||
) -> KernelResult<()> {
|
||||
self.registry
|
||||
.update_tool_filters(agent_id, allowlist.clone(), blocklist.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
|
||||
if let Some(entry) = self.registry.get(agent_id) {
|
||||
let _ = self.memory.save_agent(&entry);
|
||||
}
|
||||
|
||||
info!(
|
||||
agent_id = %agent_id,
|
||||
allowlist = ?allowlist,
|
||||
blocklist = ?blocklist,
|
||||
"Agent tool filters updated"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get session token usage and estimated cost for an agent.
|
||||
pub fn session_usage_cost(&self, agent_id: AgentId) -> KernelResult<(u64, u64, f64)> {
|
||||
let entry = self.registry.get(agent_id).ok_or_else(|| {
|
||||
@@ -2696,6 +2892,18 @@ impl OpenFangKernel {
|
||||
if let Err(e) = self.kill_agent(agent_id) {
|
||||
warn!(agent = %agent_id, error = %e, "Failed to kill hand agent (may already be dead)");
|
||||
}
|
||||
} else {
|
||||
// Fallback: if agent_id was never set (incomplete activation), search by hand tag
|
||||
let hand_tag = format!("hand:{}", instance.hand_id);
|
||||
for entry in self.registry.list() {
|
||||
if entry.tags.contains(&hand_tag) {
|
||||
if let Err(e) = self.kill_agent(entry.id) {
|
||||
warn!(agent = %entry.id, error = %e, "Failed to kill orphaned hand agent");
|
||||
} else {
|
||||
info!(agent_id = %entry.id, hand_id = %instance.hand_id, "Cleaned up orphaned hand agent");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2812,6 +3020,17 @@ impl OpenFangKernel {
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
catalog.apply_url_overrides(&new_config.provider_urls);
|
||||
}
|
||||
HotAction::UpdateDefaultModel => {
|
||||
info!(
|
||||
"Hot-reload: updating default model to {}/{}",
|
||||
new_config.default_model.provider, new_config.default_model.model
|
||||
);
|
||||
let mut guard = self
|
||||
.default_model_override
|
||||
.write()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
*guard = Some(new_config.default_model.clone());
|
||||
}
|
||||
_ => {
|
||||
// Other hot actions (channels, web, browser, extensions, etc.)
|
||||
// are logged but not applied here — they require subsystem-specific
|
||||
@@ -2966,18 +3185,30 @@ impl OpenFangKernel {
|
||||
/// `Continuous`, `Periodic`, or `Proactive` schedules.
|
||||
pub fn start_background_agents(self: &Arc<Self>) {
|
||||
let agents = self.registry.list();
|
||||
let mut started = 0u32;
|
||||
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> =
|
||||
Vec::new();
|
||||
|
||||
for entry in &agents {
|
||||
if matches!(entry.manifest.schedule, ScheduleMode::Reactive) {
|
||||
continue;
|
||||
}
|
||||
self.start_background_for_agent(entry.id, &entry.name, &entry.manifest.schedule);
|
||||
started += 1;
|
||||
bg_agents.push((entry.id, entry.name.clone(), entry.manifest.schedule.clone()));
|
||||
}
|
||||
|
||||
if started > 0 {
|
||||
info!("Started {started} background agent loop(s)");
|
||||
if !bg_agents.is_empty() {
|
||||
let count = bg_agents.len();
|
||||
let kernel = Arc::clone(self);
|
||||
// Stagger agent startup to prevent rate-limit storm on shared providers.
|
||||
// Each agent gets a 500ms delay before the next one starts.
|
||||
tokio::spawn(async move {
|
||||
for (i, (id, name, schedule)) in bg_agents.into_iter().enumerate() {
|
||||
kernel.start_background_for_agent(id, &name, &schedule);
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
info!("Started {count} background agent loop(s) (staggered)");
|
||||
});
|
||||
}
|
||||
|
||||
// Start heartbeat monitor for agent health checking
|
||||
@@ -3168,9 +3399,10 @@ impl OpenFangKernel {
|
||||
let timeout_s = timeout_secs.unwrap_or(120);
|
||||
let timeout = std::time::Duration::from_secs(timeout_s);
|
||||
let delivery = job.delivery.clone();
|
||||
let kh: std::sync::Arc<dyn openfang_runtime::kernel_handle::KernelHandle> = kernel.clone();
|
||||
match tokio::time::timeout(
|
||||
timeout,
|
||||
kernel.send_message(agent_id, message),
|
||||
kernel.send_message_with_handle(agent_id, message, Some(kh)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3538,10 +3770,14 @@ impl OpenFangKernel {
|
||||
let base_url = if has_custom_url {
|
||||
manifest.model.base_url.clone()
|
||||
} else if agent_provider == default_provider {
|
||||
self.config.default_model.base_url.clone()
|
||||
self.config
|
||||
.default_model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.provider_urls.get(agent_provider.as_str()).cloned())
|
||||
} else {
|
||||
// Let create_driver() use the target provider's default base URL
|
||||
None
|
||||
// Check provider_urls before falling back to hardcoded defaults
|
||||
self.config.provider_urls.get(agent_provider.as_str()).cloned()
|
||||
};
|
||||
|
||||
let driver_config = DriverConfig {
|
||||
@@ -3557,7 +3793,9 @@ impl OpenFangKernel {
|
||||
|
||||
// If fallback models are configured, wrap in FallbackDriver
|
||||
if !manifest.fallback_models.is_empty() {
|
||||
let mut chain = vec![primary.clone()];
|
||||
// Primary driver uses the agent's own model name (already set in request)
|
||||
let mut chain: Vec<(std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>, String)> =
|
||||
vec![(primary.clone(), String::new())];
|
||||
for fb in &manifest.fallback_models {
|
||||
let config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
@@ -3565,10 +3803,13 @@ impl OpenFangKernel {
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok()),
|
||||
base_url: fb.base_url.clone(),
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.provider_urls.get(&fb.provider).cloned()),
|
||||
};
|
||||
match drivers::create_driver(&config) {
|
||||
Ok(d) => chain.push(d),
|
||||
Ok(d) => chain.push((d, fb.model.clone())),
|
||||
Err(e) => {
|
||||
warn!("Fallback driver '{}' failed to init: {e}", fb.provider);
|
||||
}
|
||||
@@ -3576,7 +3817,7 @@ impl OpenFangKernel {
|
||||
}
|
||||
if chain.len() > 1 {
|
||||
return Ok(Arc::new(
|
||||
openfang_runtime::drivers::fallback::FallbackDriver::new(chain),
|
||||
openfang_runtime::drivers::fallback::FallbackDriver::with_models(chain),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -3976,6 +4217,31 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply per-agent tool allowlist/blocklist (manifest-level filtering)
|
||||
let (tool_allowlist, tool_blocklist) = entry
|
||||
.as_ref()
|
||||
.map(|e| (e.manifest.tool_allowlist.clone(), e.manifest.tool_blocklist.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
if !tool_allowlist.is_empty() {
|
||||
all_tools.retain(|t| tool_allowlist.iter().any(|a| a == &t.name));
|
||||
}
|
||||
if !tool_blocklist.is_empty() {
|
||||
all_tools.retain(|t| !tool_blocklist.iter().any(|b| b == &t.name));
|
||||
}
|
||||
|
||||
// Remove shell_exec from tool list if exec_policy won't allow it,
|
||||
// so the LLM doesn't try to call a tool that will be blocked.
|
||||
let exec_blocks_shell = entry.as_ref().is_some_and(|e| {
|
||||
e.manifest
|
||||
.exec_policy
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.mode == openfang_types::config::ExecSecurityMode::Deny)
|
||||
});
|
||||
if exec_blocks_shell {
|
||||
all_tools.retain(|t| t.name != "shell_exec");
|
||||
}
|
||||
|
||||
let caps = self.capabilities.list(agent_id);
|
||||
|
||||
// If agent has ToolAll, return all tools
|
||||
@@ -4251,6 +4517,27 @@ fn manifest_to_capabilities(manifest: &AgentManifest) -> Vec<Capability> {
|
||||
caps
|
||||
}
|
||||
|
||||
/// Apply global budget defaults to an agent's resource quota.
|
||||
///
|
||||
/// When the global budget config specifies limits and the agent still has
|
||||
/// the built-in defaults, override them so agents respect the user's config.
|
||||
fn apply_budget_defaults(
|
||||
budget: &openfang_types::config::BudgetConfig,
|
||||
resources: &mut ResourceQuota,
|
||||
) {
|
||||
// Only override hourly if agent has the built-in default (1.0) and global is set
|
||||
if budget.max_hourly_usd > 0.0 && resources.max_cost_per_hour_usd == 1.0 {
|
||||
resources.max_cost_per_hour_usd = budget.max_hourly_usd;
|
||||
}
|
||||
// Only override daily/monthly if agent has unlimited (0.0) and global is set
|
||||
if budget.max_daily_usd > 0.0 && resources.max_cost_per_day_usd == 0.0 {
|
||||
resources.max_cost_per_day_usd = budget.max_daily_usd;
|
||||
}
|
||||
if budget.max_monthly_usd > 0.0 && resources.max_cost_per_month_usd == 0.0 {
|
||||
resources.max_cost_per_month_usd = budget.max_monthly_usd;
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer provider from a model name when catalog lookup fails.
|
||||
///
|
||||
/// Uses well-known model name prefixes to map to the correct provider.
|
||||
@@ -4976,6 +5263,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
};
|
||||
manifest.capabilities.tools = vec!["file_read".to_string(), "web_fetch".to_string()];
|
||||
manifest.capabilities.agent_spawn = true;
|
||||
@@ -5011,6 +5300,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,27 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's tool allowlist and blocklist.
|
||||
pub fn update_tool_filters(
|
||||
&self,
|
||||
id: AgentId,
|
||||
allowlist: Option<Vec<String>>,
|
||||
blocklist: Option<Vec<String>>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
if let Some(al) = allowlist {
|
||||
entry.manifest.tool_allowlist = al;
|
||||
}
|
||||
if let Some(bl) = blocklist {
|
||||
entry.manifest.tool_blocklist = bl;
|
||||
}
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's system prompt (hot-swap, takes effect on next message).
|
||||
pub fn update_system_prompt(&self, id: AgentId, new_prompt: String) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
@@ -241,6 +262,31 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's resource quota (budget limits).
|
||||
pub fn update_resources(
|
||||
&self,
|
||||
id: AgentId,
|
||||
hourly: Option<f64>,
|
||||
daily: Option<f64>,
|
||||
monthly: Option<f64>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
if let Some(v) = hourly {
|
||||
entry.manifest.resources.max_cost_per_hour_usd = v;
|
||||
}
|
||||
if let Some(v) = daily {
|
||||
entry.manifest.resources.max_cost_per_day_usd = v;
|
||||
}
|
||||
if let Some(v) = monthly {
|
||||
entry.manifest.resources.max_cost_per_month_usd = v;
|
||||
}
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an agent's onboarding as complete.
|
||||
pub fn mark_onboarding_complete(&self, id: AgentId) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
@@ -295,6 +341,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
},
|
||||
state: AgentState::Created,
|
||||
mode: AgentMode::default(),
|
||||
|
||||
@@ -179,6 +179,8 @@ impl SetupWizard {
|
||||
profile: None,
|
||||
fallback_models: vec![],
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
};
|
||||
|
||||
let skills_to_install: Vec<String> = intent
|
||||
|
||||
@@ -128,6 +128,20 @@ impl SessionStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the canonical (cross-channel) session for an agent.
|
||||
pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM canonical_sessions WHERE agent_id = ?1",
|
||||
rusqlite::params![agent_id.0.to_string()],
|
||||
)
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all sessions with metadata (session_id, agent_id, message_count, created_at).
|
||||
pub fn list_sessions(&self) -> OpenFangResult<Vec<serde_json::Value>> {
|
||||
let conn = self
|
||||
|
||||
@@ -164,6 +164,16 @@ impl MemorySubstrate {
|
||||
self.sessions.delete_session(session_id)
|
||||
}
|
||||
|
||||
/// Delete all sessions belonging to an agent.
|
||||
pub fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
self.sessions.delete_agent_sessions(agent_id)
|
||||
}
|
||||
|
||||
/// Delete the canonical (cross-channel) session for an agent.
|
||||
pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
self.sessions.delete_canonical_session(agent_id)
|
||||
}
|
||||
|
||||
/// Set or clear a session label.
|
||||
pub fn set_session_label(
|
||||
&self,
|
||||
|
||||
@@ -29,6 +29,7 @@ hex = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
tokio-tungstenite = "0.24"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenFang Browser Bridge — Playwright automation over JSON-line stdio protocol.
|
||||
|
||||
Reads JSON commands from stdin (one per line), executes browser actions via
|
||||
Playwright, and writes JSON responses to stdout (one per line).
|
||||
|
||||
Usage:
|
||||
python browser_bridge.py [--headless] [--width 1280] [--height 720] [--timeout 30]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="OpenFang Browser Bridge")
|
||||
parser.add_argument("--headless", action="store_true", default=True)
|
||||
parser.add_argument("--no-headless", dest="headless", action="store_false")
|
||||
parser.add_argument("--width", type=int, default=1280)
|
||||
parser.add_argument("--height", type=int, default=720)
|
||||
parser.add_argument("--timeout", type=int, default=30)
|
||||
args = parser.parse_args()
|
||||
|
||||
timeout_ms = args.timeout * 1000
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
respond({"success": False, "error": "playwright not installed. Run: pip install playwright && playwright install chromium"})
|
||||
return
|
||||
|
||||
pw = sync_playwright().start()
|
||||
browser = pw.chromium.launch(headless=args.headless)
|
||||
context = browser.new_context(
|
||||
viewport={"width": args.width, "height": args.height},
|
||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(timeout_ms)
|
||||
page.set_default_navigation_timeout(timeout_ms)
|
||||
|
||||
# Signal ready
|
||||
respond({"success": True, "data": {"status": "ready"}})
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
action = None
|
||||
try:
|
||||
cmd = json.loads(line)
|
||||
action = cmd.get("action", "")
|
||||
result = handle_command(page, context, action, cmd, timeout_ms)
|
||||
respond(result)
|
||||
except Exception as e:
|
||||
respond({"success": False, "error": f"{type(e).__name__}: {e}"})
|
||||
|
||||
if action == "Close":
|
||||
break
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
context.close()
|
||||
browser.close()
|
||||
pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def handle_command(page, context, action, cmd, timeout_ms):
|
||||
if action == "Navigate":
|
||||
url = cmd.get("url", "")
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' parameter"}
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
|
||||
title = page.title()
|
||||
content = extract_readable(page)
|
||||
return {"success": True, "data": {"title": title, "url": page.url, "content": content}}
|
||||
|
||||
elif action == "Click":
|
||||
selector = cmd.get("selector", "")
|
||||
if not selector:
|
||||
return {"success": False, "error": "Missing 'selector' parameter"}
|
||||
# Try CSS selector first, then text content
|
||||
try:
|
||||
page.click(selector, timeout=timeout_ms)
|
||||
except Exception:
|
||||
# Fallback: try as text
|
||||
page.get_by_text(selector, exact=False).first.click(timeout=timeout_ms)
|
||||
page.wait_for_load_state("domcontentloaded", timeout=timeout_ms)
|
||||
title = page.title()
|
||||
return {"success": True, "data": {"clicked": selector, "title": title, "url": page.url}}
|
||||
|
||||
elif action == "Type":
|
||||
selector = cmd.get("selector", "")
|
||||
text = cmd.get("text", "")
|
||||
if not selector:
|
||||
return {"success": False, "error": "Missing 'selector' parameter"}
|
||||
if not text:
|
||||
return {"success": False, "error": "Missing 'text' parameter"}
|
||||
page.fill(selector, text, timeout=timeout_ms)
|
||||
return {"success": True, "data": {"typed": text, "selector": selector}}
|
||||
|
||||
elif action == "Screenshot":
|
||||
screenshot_bytes = page.screenshot(full_page=False)
|
||||
b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
||||
return {"success": True, "data": {"image_base64": b64, "format": "png", "url": page.url}}
|
||||
|
||||
elif action == "ReadPage":
|
||||
title = page.title()
|
||||
content = extract_readable(page)
|
||||
return {"success": True, "data": {"title": title, "url": page.url, "content": content}}
|
||||
|
||||
elif action == "Close":
|
||||
return {"success": True, "data": {"status": "closed"}}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown action: {action}"}
|
||||
|
||||
|
||||
def extract_readable(page):
|
||||
"""Extract readable text content from the page, stripping nav/footer/script noise."""
|
||||
try:
|
||||
# Remove script, style, nav, footer, header elements
|
||||
content = page.evaluate("""() => {
|
||||
const clone = document.body.cloneNode(true);
|
||||
const remove = ['script', 'style', 'nav', 'footer', 'header', 'aside',
|
||||
'iframe', 'noscript', 'svg', 'canvas'];
|
||||
remove.forEach(tag => {
|
||||
clone.querySelectorAll(tag).forEach(el => el.remove());
|
||||
});
|
||||
|
||||
// Try to find main content area
|
||||
const main = clone.querySelector('main, article, [role="main"], .content, #content');
|
||||
const source = main || clone;
|
||||
|
||||
// Extract text with basic structure
|
||||
const lines = [];
|
||||
const walk = (node) => {
|
||||
if (node.nodeType === 3) {
|
||||
const text = node.textContent.trim();
|
||||
if (text) lines.push(text);
|
||||
} else if (node.nodeType === 1) {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
||||
lines.push('\\n## ' + node.textContent.trim());
|
||||
} else if (tag === 'li') {
|
||||
lines.push('- ' + node.textContent.trim());
|
||||
} else if (tag === 'a' && node.href) {
|
||||
lines.push('[' + node.textContent.trim() + '](' + node.href + ')');
|
||||
} else if (['p', 'div', 'section', 'td', 'th'].includes(tag)) {
|
||||
for (const child of node.childNodes) walk(child);
|
||||
lines.push('');
|
||||
} else {
|
||||
for (const child of node.childNodes) walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(source);
|
||||
return lines.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
|
||||
}""")
|
||||
# Truncate to prevent huge payloads
|
||||
max_chars = 50000
|
||||
if len(content) > max_chars:
|
||||
content = content[:max_chars] + f"\n\n[Truncated — {len(content)} total chars]"
|
||||
return content
|
||||
except Exception:
|
||||
# Fallback: plain innerText
|
||||
try:
|
||||
text = page.inner_text("body")
|
||||
if len(text) > 50000:
|
||||
text = text[:50000] + f"\n\n[Truncated — {len(text)} total chars]"
|
||||
return text
|
||||
except Exception:
|
||||
return "(could not extract page content)"
|
||||
|
||||
|
||||
def respond(data):
|
||||
"""Write a JSON response line to stdout."""
|
||||
sys.stdout.write(json.dumps(data) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -145,7 +145,14 @@ pub fn apply_context_guard(
|
||||
let mut compacted = 0;
|
||||
for loc in &locations {
|
||||
if loc.char_len > single_max {
|
||||
// Bounds check: indices may be stale if messages were modified concurrently
|
||||
if loc.msg_idx >= messages.len() {
|
||||
continue;
|
||||
}
|
||||
if let MessageContent::Blocks(blocks) = &mut messages[loc.msg_idx].content {
|
||||
if loc.block_idx >= blocks.len() {
|
||||
continue;
|
||||
}
|
||||
if let ContentBlock::ToolResult { content, .. } = &mut blocks[loc.block_idx] {
|
||||
let old_len = content.len();
|
||||
*content = truncate_to(content, single_max);
|
||||
@@ -167,7 +174,13 @@ pub fn apply_context_guard(
|
||||
if loc.char_len <= compact_target {
|
||||
continue;
|
||||
}
|
||||
if loc.msg_idx >= messages.len() {
|
||||
continue;
|
||||
}
|
||||
if let MessageContent::Blocks(blocks) = &mut messages[loc.msg_idx].content {
|
||||
if loc.block_idx >= blocks.len() {
|
||||
continue;
|
||||
}
|
||||
if let ContentBlock::ToolResult { content, .. } = &mut blocks[loc.block_idx] {
|
||||
if content.len() > compact_target {
|
||||
let old_len = content.len();
|
||||
@@ -188,11 +201,32 @@ fn truncate_to(content: &str, max_chars: usize) -> String {
|
||||
if content.len() <= max_chars {
|
||||
return content.to_string();
|
||||
}
|
||||
let keep = max_chars.saturating_sub(80);
|
||||
let keep = max_chars.saturating_sub(80).min(content.len());
|
||||
// Ensure keep is a valid char boundary
|
||||
let keep = if content.is_char_boundary(keep) {
|
||||
keep
|
||||
} else {
|
||||
content[..keep]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let search_start = keep.saturating_sub(100);
|
||||
// Ensure search_start is a valid char boundary
|
||||
let search_start = if content.is_char_boundary(search_start) {
|
||||
search_start
|
||||
} else {
|
||||
content[..search_start]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
// Try to break at newline
|
||||
let break_point = content[keep.saturating_sub(100)..keep]
|
||||
let break_point = content[search_start..keep]
|
||||
.rfind('\n')
|
||||
.map(|pos| keep.saturating_sub(100) + pos)
|
||||
.map(|pos| search_start + pos)
|
||||
.unwrap_or(keep);
|
||||
format!(
|
||||
"{}\n\n[COMPACTED: {} → {} chars by context guard]",
|
||||
|
||||
@@ -365,10 +365,10 @@ impl LlmDriver for AnthropicDriver {
|
||||
let mut event_type = String::new();
|
||||
let mut data = String::new();
|
||||
for line in event_text.lines() {
|
||||
if let Some(et) = line.strip_prefix("event: ") {
|
||||
event_type = et.to_string();
|
||||
} else if let Some(d) = line.strip_prefix("data: ") {
|
||||
data = d.to_string();
|
||||
if let Some(et) = line.strip_prefix("event:") {
|
||||
event_type = et.trim_start().to_string();
|
||||
} else if let Some(d) = line.strip_prefix("data:") {
|
||||
data = d.trim_start().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,25 @@ use tracing::warn;
|
||||
|
||||
/// A driver that wraps multiple LLM drivers and tries each in order.
|
||||
///
|
||||
/// On failure, moves to the next driver. Rate-limit and overload errors
|
||||
/// are bubbled up for retry logic to handle.
|
||||
/// On failure (including rate-limit and overload), moves to the next driver.
|
||||
/// Only returns an error when ALL drivers in the chain are exhausted.
|
||||
/// Each driver is paired with the model name it should use.
|
||||
pub struct FallbackDriver {
|
||||
drivers: Vec<Arc<dyn LlmDriver>>,
|
||||
drivers: Vec<(Arc<dyn LlmDriver>, String)>,
|
||||
}
|
||||
|
||||
impl FallbackDriver {
|
||||
/// Create a new fallback driver from an ordered chain of drivers.
|
||||
/// Create a new fallback driver from an ordered chain of (driver, model_name) pairs.
|
||||
///
|
||||
/// The first driver is the primary; subsequent are fallbacks.
|
||||
/// The first entry is the primary; subsequent are fallbacks.
|
||||
pub fn new(drivers: Vec<Arc<dyn LlmDriver>>) -> Self {
|
||||
Self {
|
||||
drivers: drivers.into_iter().map(|d| (d, String::new())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new fallback driver with explicit model names for each driver.
|
||||
pub fn with_models(drivers: Vec<(Arc<dyn LlmDriver>, String)>) -> Self {
|
||||
Self { drivers }
|
||||
}
|
||||
}
|
||||
@@ -30,16 +38,26 @@ impl LlmDriver for FallbackDriver {
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let mut last_error = None;
|
||||
|
||||
for (i, driver) in self.drivers.iter().enumerate() {
|
||||
match driver.complete(request.clone()).await {
|
||||
for (i, (driver, model_name)) in self.drivers.iter().enumerate() {
|
||||
let mut req = request.clone();
|
||||
if !model_name.is_empty() {
|
||||
req.model = model_name.clone();
|
||||
}
|
||||
match driver.complete(req).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e @ LlmError::RateLimited { .. }) | Err(e @ LlmError::Overloaded { .. }) => {
|
||||
// Retryable errors — bubble up for the retry loop to handle
|
||||
return Err(e);
|
||||
warn!(
|
||||
driver_index = i,
|
||||
model = %model_name,
|
||||
error = %e,
|
||||
"Driver rate-limited/overloaded, trying next fallback"
|
||||
);
|
||||
last_error = Some(e);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
driver_index = i,
|
||||
model = %model_name,
|
||||
error = %e,
|
||||
"Fallback driver failed, trying next"
|
||||
);
|
||||
@@ -61,15 +79,26 @@ impl LlmDriver for FallbackDriver {
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
let mut last_error = None;
|
||||
|
||||
for (i, driver) in self.drivers.iter().enumerate() {
|
||||
match driver.stream(request.clone(), tx.clone()).await {
|
||||
for (i, (driver, model_name)) in self.drivers.iter().enumerate() {
|
||||
let mut req = request.clone();
|
||||
if !model_name.is_empty() {
|
||||
req.model = model_name.clone();
|
||||
}
|
||||
match driver.stream(req, tx.clone()).await {
|
||||
Ok(response) => return Ok(response),
|
||||
Err(e @ LlmError::RateLimited { .. }) | Err(e @ LlmError::Overloaded { .. }) => {
|
||||
return Err(e);
|
||||
warn!(
|
||||
driver_index = i,
|
||||
model = %model_name,
|
||||
error = %e,
|
||||
"Driver rate-limited/overloaded (stream), trying next fallback"
|
||||
);
|
||||
last_error = Some(e);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
driver_index = i,
|
||||
model = %model_name,
|
||||
error = %e,
|
||||
"Fallback driver (stream) failed, trying next"
|
||||
);
|
||||
@@ -166,7 +195,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rate_limit_bubbles_up() {
|
||||
async fn test_rate_limit_falls_through() {
|
||||
struct RateLimitDriver;
|
||||
|
||||
#[async_trait]
|
||||
@@ -186,7 +215,33 @@ mod tests {
|
||||
Arc::new(OkDriver) as Arc<dyn LlmDriver>,
|
||||
]);
|
||||
let result = driver.complete(test_request()).await;
|
||||
// Rate limit should NOT fall through to next driver
|
||||
// Rate limit should fall through to the OkDriver fallback
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().text(), "OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rate_limit_all_fail() {
|
||||
struct RateLimitDriver;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for RateLimitDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
Err(LlmError::RateLimited {
|
||||
retry_after_ms: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let driver = FallbackDriver::new(vec![
|
||||
Arc::new(RateLimitDriver) as Arc<dyn LlmDriver>,
|
||||
Arc::new(RateLimitDriver) as Arc<dyn LlmDriver>,
|
||||
]);
|
||||
let result = driver.complete(test_request()).await;
|
||||
// All drivers rate-limited — error should bubble up
|
||||
assert!(matches!(result, Err(LlmError::RateLimited { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,10 +525,10 @@ impl LlmDriver for GeminiDriver {
|
||||
let event_text = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
// Extract the data line
|
||||
// Extract the data line (handle both "data: " and "data:" formats)
|
||||
let data = event_text
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("data: "))
|
||||
.find_map(|line| line.strip_prefix("data:").map(|d| d.trim_start()))
|
||||
.unwrap_or("");
|
||||
|
||||
if data.is_empty() {
|
||||
|
||||
@@ -33,7 +33,12 @@ impl OpenAIDriver {
|
||||
struct OaiRequest {
|
||||
model: String,
|
||||
messages: Vec<OaiMessage>,
|
||||
max_tokens: u32,
|
||||
/// Classic token limit field (used by most models).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
/// New token limit field required by GPT-5 and o-series reasoning models.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_completion_tokens: Option<u32>,
|
||||
temperature: f32,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tools: Vec<OaiTool>,
|
||||
@@ -43,6 +48,16 @@ struct OaiRequest {
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
/// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`.
|
||||
fn uses_completion_tokens(model: &str) -> bool {
|
||||
let m = model.to_lowercase();
|
||||
m.starts_with("gpt-5")
|
||||
|| m.starts_with("gpt5")
|
||||
|| m.starts_with("o1")
|
||||
|| m.starts_with("o3")
|
||||
|| m.starts_with("o4")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OaiMessage {
|
||||
role: String,
|
||||
@@ -276,10 +291,16 @@ impl LlmDriver for OpenAIDriver {
|
||||
Some(serde_json::json!("auto"))
|
||||
};
|
||||
|
||||
let (mt, mct) = if uses_completion_tokens(&request.model) {
|
||||
(None, Some(request.max_tokens))
|
||||
} else {
|
||||
(Some(request.max_tokens), None)
|
||||
};
|
||||
let mut oai_request = OaiRequest {
|
||||
model: request.model.clone(),
|
||||
messages: oai_messages,
|
||||
max_tokens: request.max_tokens,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: request.temperature,
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
@@ -339,16 +360,31 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
&& (body.contains("unsupported_parameter")
|
||||
|| body.contains("max_completion_tokens"))
|
||||
&& oai_request.max_tokens.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
let val = oai_request.max_tokens.unwrap();
|
||||
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model");
|
||||
oai_request.max_tokens = None;
|
||||
oai_request.max_completion_tokens = Some(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192)
|
||||
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
|
||||
// Extract the limit from error: "must be less than or equal to `8192`"
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
|
||||
warn!(
|
||||
old = oai_request.max_tokens,
|
||||
new = cap,
|
||||
"Auto-capping max_tokens to model limit"
|
||||
);
|
||||
oai_request.max_tokens = cap;
|
||||
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
|
||||
warn!(old = current, new = cap, "Auto-capping max_tokens to model limit");
|
||||
if oai_request.max_completion_tokens.is_some() {
|
||||
oai_request.max_completion_tokens = Some(cap);
|
||||
} else {
|
||||
oai_request.max_tokens = Some(cap);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -555,10 +591,16 @@ impl LlmDriver for OpenAIDriver {
|
||||
Some(serde_json::json!("auto"))
|
||||
};
|
||||
|
||||
let (mt, mct) = if uses_completion_tokens(&request.model) {
|
||||
(None, Some(request.max_tokens))
|
||||
} else {
|
||||
(Some(request.max_tokens), None)
|
||||
};
|
||||
let mut oai_request = OaiRequest {
|
||||
model: request.model.clone(),
|
||||
messages: oai_messages,
|
||||
max_tokens: request.max_tokens,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: request.temperature,
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
@@ -620,15 +662,31 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
&& (body.contains("unsupported_parameter")
|
||||
|| body.contains("max_completion_tokens"))
|
||||
&& oai_request.max_tokens.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
let val = oai_request.max_tokens.unwrap();
|
||||
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model (stream)");
|
||||
oai_request.max_tokens = None;
|
||||
oai_request.max_completion_tokens = Some(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-cap max_tokens when model rejects our value
|
||||
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
|
||||
warn!(
|
||||
old = oai_request.max_tokens,
|
||||
new = cap,
|
||||
"Auto-capping max_tokens (stream)"
|
||||
);
|
||||
oai_request.max_tokens = cap;
|
||||
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
|
||||
warn!(old = current, new = cap, "Auto-capping max_tokens (stream)");
|
||||
if oai_request.max_completion_tokens.is_some() {
|
||||
oai_request.max_completion_tokens = Some(cap);
|
||||
} else {
|
||||
oai_request.max_tokens = Some(cap);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -660,8 +718,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = match line.strip_prefix("data: ") {
|
||||
Some(d) => d,
|
||||
let data = match line.strip_prefix("data:") {
|
||||
Some(d) => d.trim_start(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ pub struct McpServerConfig {
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
30
|
||||
60
|
||||
}
|
||||
|
||||
/// Transport type for MCP server connections.
|
||||
@@ -393,7 +393,31 @@ impl McpConnection {
|
||||
return Err("MCP command path contains '..': rejected".to_string());
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new(command);
|
||||
// On Windows, npm/npx install as .cmd batch wrappers. Detect and adapt.
|
||||
let resolved_command: String = if cfg!(windows) {
|
||||
// If the user already specified .cmd/.bat, use as-is
|
||||
if command.ends_with(".cmd") || command.ends_with(".bat") {
|
||||
command.to_string()
|
||||
} else {
|
||||
// Check if the .cmd variant exists on PATH
|
||||
let cmd_variant = format!("{command}.cmd");
|
||||
let has_cmd = std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.any(|dir| {
|
||||
std::path::Path::new(dir).join(&cmd_variant).exists()
|
||||
});
|
||||
if has_cmd {
|
||||
cmd_variant
|
||||
} else {
|
||||
command.to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
command.to_string()
|
||||
};
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&resolved_command);
|
||||
cmd.args(args);
|
||||
cmd.stdin(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped());
|
||||
@@ -410,10 +434,41 @@ impl McpConnection {
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
// On Windows, npm/node need APPDATA, USERPROFILE, LOCALAPPDATA, and SystemRoot
|
||||
if cfg!(windows) {
|
||||
for var in &[
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"SystemRoot",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"HOME",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn MCP server '{command}': {e}"))?;
|
||||
.map_err(|e| format!("Failed to spawn MCP server '{resolved_command}': {e}"))?;
|
||||
|
||||
// Log stderr in background for debugging MCP server issues
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let cmd_name = resolved_command.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let reader = tokio::io::BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(mcp_server = %cmd_name, "stderr: {line}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
|
||||
@@ -115,6 +115,19 @@ impl ModelCatalog {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return the default model ID for a provider (first model in catalog order).
|
||||
pub fn default_model_for_provider(&self, provider: &str) -> Option<String> {
|
||||
// Check aliases first — e.g. "minimax" alias resolves to "MiniMax-M2.5"
|
||||
if let Some(model_id) = self.aliases.get(provider) {
|
||||
return Some(model_id.clone());
|
||||
}
|
||||
// Fall back to the first model registered for this provider
|
||||
self.models
|
||||
.iter()
|
||||
.find(|m| m.provider == provider)
|
||||
.map(|m| m.id.clone())
|
||||
}
|
||||
|
||||
/// List models that are available (from configured providers only).
|
||||
pub fn available_models(&self) -> Vec<&ModelCatalogEntry> {
|
||||
let configured: Vec<&str> = self
|
||||
@@ -634,8 +647,8 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("gpt4-mini", "gpt-4o-mini"),
|
||||
("gpt5", "gpt-5.2"),
|
||||
("gpt5-mini", "gpt-5-mini"),
|
||||
("flash", "gemini-3-flash"),
|
||||
("gemini-flash", "gemini-3-flash"),
|
||||
("flash", "gemini-2.5-flash"),
|
||||
("gemini-flash", "gemini-2.5-flash"),
|
||||
("gemini-pro", "gemini-3.1-pro"),
|
||||
("deepseek", "deepseek-chat"),
|
||||
("llama", "llama-3.3-70b-versatile"),
|
||||
@@ -1039,20 +1052,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec!["gemini-pro".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-3-flash".into(),
|
||||
display_name: "Gemini 3 Flash".into(),
|
||||
provider: "gemini".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 65_536,
|
||||
input_cost_per_m: 0.50,
|
||||
output_cost_per_m: 3.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["flash".into(), "gemini-flash".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(),
|
||||
|
||||
@@ -51,6 +51,10 @@ pub struct PromptContext {
|
||||
pub identity_md: Option<String>,
|
||||
/// HEARTBEAT.md content (autonomous agent checklist).
|
||||
pub heartbeat_md: Option<String>,
|
||||
/// Peer agents visible to this agent: (name, state, model).
|
||||
pub peer_agents: Vec<(String, String, String)>,
|
||||
/// Current date/time string for temporal awareness.
|
||||
pub current_date: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the complete system prompt from a `PromptContext`.
|
||||
@@ -64,6 +68,11 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
|
||||
// Section 1 — Agent Identity (always present)
|
||||
sections.push(build_identity_section(ctx));
|
||||
|
||||
// Section 1.5 — Current Date/Time (always present when set)
|
||||
if let Some(ref date) = ctx.current_date {
|
||||
sections.push(format!("## Current Date\nToday is {date}."));
|
||||
}
|
||||
|
||||
// Section 2 — Tool Call Behavior (skip for subagents)
|
||||
if !ctx.is_subagent {
|
||||
sections.push(TOOL_CALL_BEHAVIOR.to_string());
|
||||
@@ -139,6 +148,11 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Section 9.5 — Peer Agent Awareness (skip for subagents)
|
||||
if !ctx.is_subagent && !ctx.peer_agents.is_empty() {
|
||||
sections.push(build_peer_agents_section(&ctx.agent_name, &ctx.peer_agents));
|
||||
}
|
||||
|
||||
// Section 10 — Safety & Oversight (skip for subagents)
|
||||
if !ctx.is_subagent {
|
||||
sections.push(SAFETY_SECTION.to_string());
|
||||
@@ -201,7 +215,13 @@ const TOOL_CALL_BEHAVIOR: &str = "\
|
||||
- Prefer action over narration. If you can answer by using a tool, do it.
|
||||
- When executing multiple sequential tool calls, batch them — don't output reasoning between each call.
|
||||
- If a tool returns useful results, present the KEY information, not the raw output.
|
||||
- Start with the answer, not meta-commentary about how you'll help.";
|
||||
- When web_fetch or web_search returns content, you MUST include the relevant data in your response. \
|
||||
Quote specific facts, numbers, or passages from the fetched content. Never say you fetched something \
|
||||
without sharing what you found.
|
||||
- Start with the answer, not meta-commentary about how you'll help.
|
||||
- IMPORTANT: If your instructions or persona mention a shell command, script path, or code snippet, \
|
||||
execute it via the appropriate tool call (shell_exec, file_write, etc.). Never output commands as \
|
||||
code blocks — always call the tool instead.";
|
||||
|
||||
/// Build the grouped tools section (Section 3).
|
||||
pub fn build_tools_section(granted_tools: &[String]) -> String {
|
||||
@@ -315,9 +335,10 @@ fn build_persona_section(
|
||||
|
||||
if let Some(soul) = soul_md {
|
||||
if !soul.trim().is_empty() {
|
||||
let sanitized = strip_code_blocks(soul);
|
||||
parts.push(format!(
|
||||
"## Persona\nEmbody this identity in your tone and communication style. Be natural, not stiff or generic.\n{}",
|
||||
cap_str(soul, 1000)
|
||||
cap_str(&sanitized, 1000)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -392,6 +413,24 @@ fn build_channel_section(channel: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_peer_agents_section(self_name: &str, peers: &[(String, String, String)]) -> String {
|
||||
let mut out = String::from(
|
||||
"## Peer Agents\n\
|
||||
You are part of a multi-agent system. These agents are running alongside you:\n",
|
||||
);
|
||||
for (name, state, model) in peers {
|
||||
if name == self_name {
|
||||
continue; // Don't list yourself
|
||||
}
|
||||
out.push_str(&format!("- **{}** ({}) — model: {}\n", name, state, model));
|
||||
}
|
||||
out.push_str(
|
||||
"\nYou can communicate with them using `agent_send` (by name) and see all agents with `agent_list`. \
|
||||
Delegate tasks to specialized agents when appropriate.",
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Static safety section.
|
||||
const SAFETY_SECTION: &str = "\
|
||||
## Safety
|
||||
@@ -527,6 +566,30 @@ pub fn tool_hint(name: &str) -> &'static str {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cap a string to `max_chars`, appending "..." if truncated.
|
||||
/// Strip markdown triple-backtick code blocks from content.
|
||||
///
|
||||
/// Prevents LLMs from copying code blocks as text output instead of making
|
||||
/// tool calls when SOUL.md contains command examples.
|
||||
fn strip_code_blocks(content: &str) -> String {
|
||||
let mut result = String::with_capacity(content.len());
|
||||
let mut in_block = false;
|
||||
for line in content.lines() {
|
||||
if line.trim_start().starts_with("```") {
|
||||
in_block = !in_block;
|
||||
continue;
|
||||
}
|
||||
if !in_block {
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
// Collapse multiple blank lines left by stripped blocks
|
||||
while result.contains("\n\n\n") {
|
||||
result = result.replace("\n\n\n", "\n\n");
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
fn cap_str(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
s.to_string()
|
||||
|
||||
@@ -187,8 +187,11 @@ pub async fn execute_tool(
|
||||
is_error: true,
|
||||
};
|
||||
}
|
||||
let method = input["method"].as_str().unwrap_or("GET");
|
||||
let headers = input.get("headers").and_then(|v| v.as_object());
|
||||
let body = input["body"].as_str();
|
||||
if let Some(ctx) = web_ctx {
|
||||
ctx.fetch.fetch(url).await
|
||||
ctx.fetch.fetch_with_options(url, method, headers, body).await
|
||||
} else {
|
||||
tool_web_fetch_legacy(input).await
|
||||
}
|
||||
@@ -213,7 +216,11 @@ pub async fn execute_tool(
|
||||
{
|
||||
return ToolResult {
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
content: format!("Exec policy denied: {reason}"),
|
||||
content: format!(
|
||||
"shell_exec blocked: {reason}. Current exec_policy.mode = '{:?}'. \
|
||||
To allow shell commands, set exec_policy.mode = 'full' in the agent manifest or config.toml.",
|
||||
policy.mode
|
||||
),
|
||||
is_error: true,
|
||||
};
|
||||
}
|
||||
@@ -330,7 +337,7 @@ pub async fn execute_tool(
|
||||
crate::browser::tool_browser_navigate(input, mgr, aid).await
|
||||
}
|
||||
None => Err(
|
||||
"Browser tools not available. Ensure Python and playwright are installed."
|
||||
"Browser tools not available. Ensure Chrome/Chromium is installed."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
@@ -340,35 +347,63 @@ pub async fn execute_tool(
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_click(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available.".to_string()),
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
"browser_type" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_type(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available.".to_string()),
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
"browser_screenshot" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_screenshot(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available.".to_string()),
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
"browser_read_page" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_read_page(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available.".to_string()),
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
"browser_close" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_close(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available.".to_string()),
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
"browser_scroll" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_scroll(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
"browser_wait" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_wait(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
"browser_run_js" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_run_js(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
"browser_back" => match browser_ctx {
|
||||
Some(mgr) => {
|
||||
let aid = caller_agent_id.unwrap_or("default");
|
||||
crate::browser::tool_browser_back(input, mgr, aid).await
|
||||
}
|
||||
None => Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string()),
|
||||
},
|
||||
|
||||
// Canvas / A2UI tool
|
||||
@@ -501,11 +536,14 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
// --- Web tools ---
|
||||
ToolDefinition {
|
||||
name: "web_fetch".to_string(),
|
||||
description: "Fetch a web page and extract its content as Markdown. Includes SSRF protection and result caching.".to_string(),
|
||||
description: "Fetch a URL with SSRF protection. Supports GET/POST/PUT/PATCH/DELETE. For GET, HTML is converted to Markdown. For other methods, returns raw response body.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "type": "string", "description": "The URL to fetch (http/https only)" }
|
||||
"url": { "type": "string", "description": "The URL to fetch (http/https only)" },
|
||||
"method": { "type": "string", "enum": ["GET","POST","PUT","PATCH","DELETE"], "description": "HTTP method (default: GET)" },
|
||||
"headers": { "type": "object", "description": "Custom HTTP headers as key-value pairs" },
|
||||
"body": { "type": "string", "description": "Request body for POST/PUT/PATCH" }
|
||||
},
|
||||
"required": ["url"]
|
||||
}),
|
||||
@@ -828,6 +866,48 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "browser_scroll".to_string(),
|
||||
description: "Scroll the browser page. Use this to see content below the fold or navigate long pages.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"direction": { "type": "string", "description": "Scroll direction: 'up', 'down', 'left', 'right' (default: 'down')" },
|
||||
"amount": { "type": "integer", "description": "Pixels to scroll (default: 600)" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "browser_wait".to_string(),
|
||||
description: "Wait for a CSS selector to appear on the page. Useful for dynamic content that loads asynchronously.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": { "type": "string", "description": "CSS selector to wait for" },
|
||||
"timeout_ms": { "type": "integer", "description": "Max wait time in milliseconds (default: 5000, max: 30000)" }
|
||||
},
|
||||
"required": ["selector"]
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "browser_run_js".to_string(),
|
||||
description: "Run JavaScript on the current browser page and return the result. For advanced interactions that other browser tools cannot handle.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": { "type": "string", "description": "JavaScript expression to run in the page context" }
|
||||
},
|
||||
"required": ["expression"]
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "browser_back".to_string(),
|
||||
description: "Go back to the previous page in browser history.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
},
|
||||
// --- Media understanding tools ---
|
||||
ToolDefinition {
|
||||
name: "media_describe".to_string(),
|
||||
@@ -3018,6 +3098,10 @@ mod tests {
|
||||
assert!(names.contains(&"browser_screenshot"));
|
||||
assert!(names.contains(&"browser_read_page"));
|
||||
assert!(names.contains(&"browser_close"));
|
||||
assert!(names.contains(&"browser_scroll"));
|
||||
assert!(names.contains(&"browser_wait"));
|
||||
assert!(names.contains(&"browser_run_js"));
|
||||
assert!(names.contains(&"browser_back"));
|
||||
// 3 media/image generation tools
|
||||
assert!(names.contains(&"media_describe"));
|
||||
assert!(names.contains(&"media_transcribe"));
|
||||
|
||||
@@ -6,6 +6,31 @@
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ASCII case-insensitive find — byte offsets always valid on original string
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Find `needle` in `haystack` starting at byte offset `from`, comparing
|
||||
/// ASCII characters case-insensitively. Since HTML tags are ASCII, this
|
||||
/// avoids the byte-length mismatch caused by `str::to_lowercase()` on
|
||||
/// multi-byte Unicode (e.g. `İ` 2 bytes → `i̇` 4 bytes).
|
||||
fn find_ci(haystack: &str, needle: &str, from: usize) -> Option<usize> {
|
||||
let h = haystack.as_bytes();
|
||||
let n = needle.as_bytes();
|
||||
if n.is_empty() || from + n.len() > h.len() {
|
||||
return None;
|
||||
}
|
||||
'outer: for i in from..=(h.len() - n.len()) {
|
||||
for j in 0..n.len() {
|
||||
if !h[i + j].eq_ignore_ascii_case(&n[j]) {
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
return Some(i);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External content markers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,19 +104,17 @@ fn remove_non_content_blocks(html: &str) -> String {
|
||||
/// Remove all occurrences of a specific tag and its contents (case-insensitive).
|
||||
fn remove_tag_blocks(html: &str, tag: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let lower = html.to_lowercase();
|
||||
let open_tag = format!("<{}", tag);
|
||||
let close_tag = format!("</{}>", tag);
|
||||
|
||||
let mut pos = 0;
|
||||
while pos < html.len() {
|
||||
if let Some(start) = lower[pos..].find(&open_tag) {
|
||||
let abs_start = pos + start;
|
||||
if let Some(abs_start) = find_ci(html, &open_tag, pos) {
|
||||
result.push_str(&html[pos..abs_start]);
|
||||
|
||||
// Find the matching close tag
|
||||
if let Some(end) = lower[abs_start..].find(&close_tag) {
|
||||
pos = abs_start + end + close_tag.len();
|
||||
if let Some(end) = find_ci(html, &close_tag, abs_start) {
|
||||
pos = end + close_tag.len();
|
||||
} else {
|
||||
// No close tag — remove to end of self-closing or skip the open tag
|
||||
if let Some(gt) = html[abs_start..].find('>') {
|
||||
@@ -110,16 +133,15 @@ fn remove_tag_blocks(html: &str, tag: &str) -> String {
|
||||
|
||||
/// Extract the content from <main>, <article>, or <body> (in priority order).
|
||||
fn extract_main_content(html: &str) -> String {
|
||||
let lower = html.to_lowercase();
|
||||
for tag in &["main", "article", "body"] {
|
||||
let open = format!("<{}", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
if let Some(start) = lower.find(&open) {
|
||||
if let Some(start) = find_ci(html, &open, 0) {
|
||||
// Skip past the opening tag's >
|
||||
if let Some(gt) = html[start..].find('>') {
|
||||
let content_start = start + gt + 1;
|
||||
if let Some(end) = lower[content_start..].find(&close) {
|
||||
return html[content_start..content_start + end].to_string();
|
||||
if let Some(end) = find_ci(html, &close, content_start) {
|
||||
return html[content_start..end].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,23 +215,21 @@ fn convert_inline_tag(
|
||||
md_close: &str,
|
||||
) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let lower = html.to_lowercase();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < html.len() {
|
||||
if let Some(start) = lower[pos..].find(open_prefix) {
|
||||
let abs_start = pos + start;
|
||||
if let Some(abs_start) = find_ci(html, open_prefix, pos) {
|
||||
result.push_str(&html[pos..abs_start]);
|
||||
|
||||
// Find the end of the opening tag
|
||||
if let Some(gt) = html[abs_start..].find('>') {
|
||||
let content_start = abs_start + gt + 1;
|
||||
// Find the close tag
|
||||
if let Some(end) = lower[content_start..].find(close) {
|
||||
if let Some(end) = find_ci(html, close, content_start) {
|
||||
result.push_str(md_open);
|
||||
result.push_str(&html[content_start..content_start + end]);
|
||||
result.push_str(&html[content_start..end]);
|
||||
result.push_str(md_close);
|
||||
pos = content_start + end + close.len();
|
||||
pos = end + close.len();
|
||||
} else {
|
||||
// No close tag, just skip the open tag
|
||||
result.push_str(md_open);
|
||||
@@ -230,12 +250,10 @@ fn convert_inline_tag(
|
||||
/// Convert <a href="url">text</a> to [text](url).
|
||||
fn convert_links(html: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let lower = html.to_lowercase();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < html.len() {
|
||||
if let Some(start) = lower[pos..].find("<a ") {
|
||||
let abs_start = pos + start;
|
||||
if let Some(abs_start) = find_ci(html, "<a ", pos) {
|
||||
result.push_str(&html[pos..abs_start]);
|
||||
|
||||
// Extract href
|
||||
@@ -244,14 +262,14 @@ fn convert_links(html: &str) -> String {
|
||||
|
||||
if let Some(gt) = tag_content.find('>') {
|
||||
let text_start = abs_start + gt + 1;
|
||||
if let Some(end) = lower[text_start..].find("</a>") {
|
||||
let link_text = strip_all_tags(&html[text_start..text_start + end]);
|
||||
if let Some(end) = find_ci(html, "</a>", text_start) {
|
||||
let link_text = strip_all_tags(&html[text_start..end]);
|
||||
if let Some(url) = href {
|
||||
result.push_str(&format!("[{}]({})", link_text.trim(), url));
|
||||
} else {
|
||||
result.push_str(link_text.trim());
|
||||
}
|
||||
pos = text_start + end + 4; // skip </a>
|
||||
pos = end + 4; // skip </a>
|
||||
} else {
|
||||
pos = text_start;
|
||||
}
|
||||
@@ -269,9 +287,8 @@ fn convert_links(html: &str) -> String {
|
||||
|
||||
/// Extract an attribute value from an HTML tag.
|
||||
fn extract_attribute(tag: &str, attr: &str) -> Option<String> {
|
||||
let lower = tag.to_lowercase();
|
||||
let pattern = format!("{}=\"", attr);
|
||||
if let Some(start) = lower.find(&pattern) {
|
||||
if let Some(start) = find_ci(tag, &pattern, 0) {
|
||||
let val_start = start + pattern.len();
|
||||
if let Some(end) = tag[val_start..].find('"') {
|
||||
return Some(tag[val_start..val_start + end].to_string());
|
||||
@@ -279,7 +296,7 @@ fn extract_attribute(tag: &str, attr: &str) -> Option<String> {
|
||||
}
|
||||
// Try single quotes
|
||||
let pattern_sq = format!("{}='", attr);
|
||||
if let Some(start) = lower.find(&pattern_sq) {
|
||||
if let Some(start) = find_ci(tag, &pattern_sq, 0) {
|
||||
let val_start = start + pattern_sq.len();
|
||||
if let Some(end) = tag[val_start..].find('\'') {
|
||||
return Some(tag[val_start..val_start + end].to_string());
|
||||
@@ -389,4 +406,41 @@ mod tests {
|
||||
assert!(result.contains("Keep"));
|
||||
assert!(result.contains("this"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_ci_basic() {
|
||||
assert_eq!(find_ci("Hello World", "hello", 0), Some(0));
|
||||
assert_eq!(find_ci("Hello World", "WORLD", 0), Some(6));
|
||||
assert_eq!(find_ci("Hello World", "xyz", 0), None);
|
||||
assert_eq!(find_ci("Hello World", "world", 6), Some(6));
|
||||
assert_eq!(find_ci("Hello World", "hello", 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_no_panic() {
|
||||
// Turkish dotted I: İ is 2 bytes, but lowercase i̇ is 4 bytes.
|
||||
// German sharp S: ẞ is 3 bytes, lowercase ß is 2 bytes.
|
||||
// This used to panic because to_lowercase() changed byte lengths.
|
||||
let html = "<body>İstanbul ẞtraße <B>bold</B> text</body>";
|
||||
let md = html_to_markdown(html);
|
||||
assert!(md.contains("**bold**"), "Expected bold, got: {md}");
|
||||
assert!(md.contains("İstanbul"), "Expected unicode preserved, got: {md}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_in_script_removal() {
|
||||
let html = "<div>Ünïcödé <SCRIPT>İstanbul</SCRIPT> keep</div>";
|
||||
let result = remove_non_content_blocks(html);
|
||||
assert!(!result.contains("İstanbul"));
|
||||
assert!(result.contains("Ünïcödé"));
|
||||
assert!(result.contains("keep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_case_tags() {
|
||||
let html = "<HTML><BODY><H1>Title</H1><P>Hello <STRONG>world</STRONG>.</P></BODY></HTML>";
|
||||
let md = html_to_markdown(html);
|
||||
assert!(md.contains("# Title"), "Expected heading, got: {md}");
|
||||
assert!(md.contains("**world**"), "Expected bold, got: {md}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,23 +32,62 @@ impl WebFetchEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a URL with full security pipeline.
|
||||
/// Fetch a URL with full security pipeline (GET only, for backwards compat).
|
||||
pub async fn fetch(&self, url: &str) -> Result<String, String> {
|
||||
self.fetch_with_options(url, "GET", None, None).await
|
||||
}
|
||||
|
||||
/// Fetch a URL with configurable HTTP method, headers, and body.
|
||||
pub async fn fetch_with_options(
|
||||
&self,
|
||||
url: &str,
|
||||
method: &str,
|
||||
headers: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
body: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let method_upper = method.to_uppercase();
|
||||
|
||||
// Step 1: SSRF protection — BEFORE any network I/O
|
||||
check_ssrf(url)?;
|
||||
|
||||
// Step 2: Cache lookup
|
||||
let cache_key = format!("fetch:{}", url);
|
||||
if let Some(cached) = self.cache.get(&cache_key) {
|
||||
debug!(url, "Fetch cache hit");
|
||||
return Ok(cached);
|
||||
// Step 2: Cache lookup (only for GET)
|
||||
let cache_key = format!("fetch:{}:{}", method_upper, url);
|
||||
if method_upper == "GET" {
|
||||
if let Some(cached) = self.cache.get(&cache_key) {
|
||||
debug!(url, "Fetch cache hit");
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: HTTP GET
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
|
||||
// Step 3: Build request with configured method
|
||||
let mut req = match method_upper.as_str() {
|
||||
"POST" => self.client.post(url),
|
||||
"PUT" => self.client.put(url),
|
||||
"PATCH" => self.client.patch(url),
|
||||
"DELETE" => self.client.delete(url),
|
||||
_ => self.client.get(url),
|
||||
};
|
||||
req = req.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)");
|
||||
|
||||
// Add custom headers
|
||||
if let Some(hdrs) = headers {
|
||||
for (k, v) in hdrs {
|
||||
if let Some(val) = v.as_str() {
|
||||
req = req.header(k.as_str(), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add body for non-GET methods
|
||||
if let Some(b) = body {
|
||||
// Auto-detect JSON body
|
||||
if b.trim_start().starts_with('{') || b.trim_start().starts_with('[') {
|
||||
req = req.header("Content-Type", "application/json");
|
||||
}
|
||||
req = req.body(b.to_string());
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
@@ -72,22 +111,25 @@ impl WebFetchEngine {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let body = resp
|
||||
let resp_body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
|
||||
// Step 4: Detect HTML and optionally convert to Markdown
|
||||
let processed = if self.config.readability && is_html(&content_type, &body) {
|
||||
let markdown = html_to_markdown(&body);
|
||||
// Step 4: For GET requests, detect HTML and convert to Markdown.
|
||||
// For non-GET (API calls), return raw body — don't mangle JSON/XML responses.
|
||||
let processed = if method_upper == "GET"
|
||||
&& self.config.readability
|
||||
&& is_html(&content_type, &resp_body)
|
||||
{
|
||||
let markdown = html_to_markdown(&resp_body);
|
||||
if markdown.trim().is_empty() {
|
||||
// Fallback to raw text if extraction produced nothing
|
||||
body
|
||||
resp_body
|
||||
} else {
|
||||
markdown
|
||||
}
|
||||
} else {
|
||||
body
|
||||
resp_body
|
||||
};
|
||||
|
||||
// Step 5: Truncate
|
||||
@@ -107,8 +149,10 @@ impl WebFetchEngine {
|
||||
wrap_external_content(url, &truncated)
|
||||
);
|
||||
|
||||
// Step 7: Cache
|
||||
self.cache.put(cache_key, result.clone());
|
||||
// Step 7: Cache (only GET responses)
|
||||
if method_upper == "GET" {
|
||||
self.cache.put(cache_key, result.clone());
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -476,6 +476,12 @@ pub struct AgentManifest {
|
||||
/// Per-agent exec policy override. If None, uses global exec_policy.
|
||||
#[serde(default)]
|
||||
pub exec_policy: Option<crate::config::ExecPolicy>,
|
||||
/// Tool allowlist — only these tools are available (empty = all tools).
|
||||
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
|
||||
pub tool_allowlist: Vec<String>,
|
||||
/// Tool blocklist — these tools are excluded (applied after allowlist).
|
||||
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
|
||||
pub tool_blocklist: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -508,6 +514,8 @@ impl Default for AgentManifest {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: Vec::new(),
|
||||
tool_blocklist: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -763,6 +771,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: Vec::new(),
|
||||
tool_blocklist: Vec::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&manifest).unwrap();
|
||||
let deserialized: AgentManifest = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -281,8 +281,8 @@ pub struct BrowserConfig {
|
||||
pub idle_timeout_secs: u64,
|
||||
/// Maximum concurrent browser sessions.
|
||||
pub max_sessions: usize,
|
||||
/// Python executable path (e.g., "python3" on Unix, "python" on Windows).
|
||||
pub python_path: String,
|
||||
/// Path to Chromium/Chrome binary. Auto-detected if None.
|
||||
pub chromium_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for BrowserConfig {
|
||||
@@ -294,11 +294,7 @@ impl Default for BrowserConfig {
|
||||
timeout_secs: 30,
|
||||
idle_timeout_secs: 300,
|
||||
max_sessions: 5,
|
||||
python_path: if cfg!(windows) {
|
||||
"python".to_string()
|
||||
} else {
|
||||
"python3".to_string()
|
||||
},
|
||||
chromium_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -750,11 +746,14 @@ impl Default for CanvasConfig {
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExecSecurityMode {
|
||||
/// Block all shell execution.
|
||||
#[serde(alias = "none", alias = "disabled")]
|
||||
Deny,
|
||||
/// Only allow commands in safe_bins or allowed_commands.
|
||||
#[default]
|
||||
#[serde(alias = "restricted")]
|
||||
Allowlist,
|
||||
/// Allow all commands (unsafe, dev only).
|
||||
#[serde(alias = "allow", alias = "all", alias = "unrestricted")]
|
||||
Full,
|
||||
}
|
||||
|
||||
@@ -1553,10 +1552,11 @@ pub struct DiscordConfig {
|
||||
/// Env var name holding the bot token (NOT the token itself).
|
||||
pub bot_token_env: String,
|
||||
/// Guild (server) IDs allowed to interact (empty = allow all).
|
||||
pub allowed_guilds: Vec<u64>,
|
||||
/// Accepts strings for consistency with other channel configs.
|
||||
pub allowed_guilds: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
/// Gateway intents bitmask (default: 33280 = GUILD_MESSAGES | MESSAGE_CONTENT).
|
||||
/// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
|
||||
pub intents: u64,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
@@ -1569,7 +1569,7 @@ impl Default for DiscordConfig {
|
||||
bot_token_env: "DISCORD_BOT_TOKEN".to_string(),
|
||||
allowed_guilds: vec![],
|
||||
default_agent: None,
|
||||
intents: 33280,
|
||||
intents: 37376,
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
@@ -3226,7 +3226,7 @@ mod tests {
|
||||
let dc = DiscordConfig::default();
|
||||
assert_eq!(dc.bot_token_env, "DISCORD_BOT_TOKEN");
|
||||
assert!(dc.allowed_guilds.is_empty());
|
||||
assert_eq!(dc.intents, 33280);
|
||||
assert_eq!(dc.intents, 37376);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -34,7 +34,7 @@ pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
|
||||
|
||||
// ── Chinese providers ─────────────────────────────────────────────
|
||||
pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.chat/v1";
|
||||
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
|
||||
pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
|
||||
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
|
||||
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
|
||||
|
||||
Reference in New Issue
Block a user