mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-17 10:22:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c81c187c4 | ||
|
|
7ae80b1b9f | ||
|
|
e58ae3e304 | ||
|
|
74f5a91fdd | ||
|
|
6de0447e8c |
Generated
+14
-14
@@ -3866,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3902,7 +3902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3933,7 +3933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3960,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3986,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4014,7 +4014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4031,7 +4031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4067,7 +4067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4086,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4105,7 +4105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4136,7 +4136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4158,7 +4158,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4177,7 +4177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -8789,7 +8789,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.2.4"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
|
||||
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
@@ -101,13 +101,20 @@ pub async fn auth(
|
||||
|| path == "/api/network/status"
|
||||
|| path == "/api/a2a/agents"
|
||||
|| path == "/api/approvals"
|
||||
|| path.starts_with("/api/approvals/")
|
||||
|| path == "/api/channels"
|
||||
|| path == "/api/hands"
|
||||
|| path == "/api/hands/active"
|
||||
|| path.starts_with("/api/hands/")
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path == "/api/workflows"
|
||||
|| path == "/api/logs/stream"
|
||||
|| path.starts_with("/api/cron/")
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
@@ -3520,6 +3520,83 @@ pub async fn deactivate_hand(
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/hands/{hand_id}/settings — Get settings schema and current values for a hand.
|
||||
pub async fn get_hand_settings(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(hand_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let settings_status = match state
|
||||
.kernel
|
||||
.hand_registry
|
||||
.check_settings_availability(&hand_id)
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Hand not found: {hand_id}")})),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Find active instance config values (if any)
|
||||
let instance_config: std::collections::HashMap<String, serde_json::Value> = state
|
||||
.kernel
|
||||
.hand_registry
|
||||
.list_instances()
|
||||
.iter()
|
||||
.find(|i| i.hand_id == hand_id)
|
||||
.map(|i| i.config.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"hand_id": hand_id,
|
||||
"settings": settings_status,
|
||||
"current_values": instance_config,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// PUT /api/hands/{hand_id}/settings — Update settings for a hand instance.
|
||||
pub async fn update_hand_settings(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(hand_id): Path<String>,
|
||||
Json(config): Json<std::collections::HashMap<String, serde_json::Value>>,
|
||||
) -> impl IntoResponse {
|
||||
// Find active instance for this hand
|
||||
let instance_id = state
|
||||
.kernel
|
||||
.hand_registry
|
||||
.list_instances()
|
||||
.iter()
|
||||
.find(|i| i.hand_id == hand_id)
|
||||
.map(|i| i.instance_id);
|
||||
|
||||
match instance_id {
|
||||
Some(id) => match state.kernel.hand_registry.update_config(id, config.clone()) {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"hand_id": hand_id,
|
||||
"instance_id": id,
|
||||
"config": config,
|
||||
})),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
),
|
||||
},
|
||||
None => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("No active instance for hand: {hand_id}. Activate the hand first.")})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/hands/instances/{id}/stats — Get dashboard stats for a hand instance.
|
||||
pub async fn hand_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -4884,6 +4961,134 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
|
||||
)
|
||||
}
|
||||
|
||||
/// POST /api/models/custom — Add a custom model to the catalog.
|
||||
///
|
||||
/// Persists to `~/.openfang/custom_models.json` and makes the model immediately
|
||||
/// available for agent assignment.
|
||||
pub async fn add_custom_model(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let id = body
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let provider = body
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("openrouter")
|
||||
.to_string();
|
||||
let context_window = body
|
||||
.get("context_window")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(128_000);
|
||||
let max_output = body
|
||||
.get("max_output_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(8_192);
|
||||
|
||||
if id.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Missing required field: id"})),
|
||||
);
|
||||
}
|
||||
|
||||
let display = body
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&id)
|
||||
.to_string();
|
||||
|
||||
let entry = openfang_types::model_catalog::ModelCatalogEntry {
|
||||
id: id.clone(),
|
||||
display_name: display,
|
||||
provider: provider.clone(),
|
||||
tier: openfang_types::model_catalog::ModelTier::Custom,
|
||||
context_window,
|
||||
max_output_tokens: max_output,
|
||||
input_cost_per_m: body
|
||||
.get("input_cost_per_m")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0),
|
||||
output_cost_per_m: body
|
||||
.get("output_cost_per_m")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0),
|
||||
supports_tools: body
|
||||
.get("supports_tools")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true),
|
||||
supports_vision: body
|
||||
.get("supports_vision")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
supports_streaming: body
|
||||
.get("supports_streaming")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true),
|
||||
aliases: vec![],
|
||||
};
|
||||
|
||||
let mut catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
if !catalog.add_custom_model(entry) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({"error": format!("Model '{}' already exists", id)})),
|
||||
);
|
||||
}
|
||||
|
||||
// Persist to disk
|
||||
let custom_path = state.kernel.config.home_dir.join("custom_models.json");
|
||||
if let Err(e) = catalog.save_custom_models(&custom_path) {
|
||||
tracing::warn!("Failed to persist custom models: {e}");
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({
|
||||
"id": id,
|
||||
"provider": provider,
|
||||
"status": "added"
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// DELETE /api/models/custom/{id} — Remove a custom model.
|
||||
pub async fn remove_custom_model(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Path(model_id): axum::extract::Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let mut catalog = state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
if !catalog.remove_custom_model(&model_id) {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Custom model '{}' not found", model_id)})),
|
||||
);
|
||||
}
|
||||
|
||||
let custom_path = state.kernel.config.home_dir.join("custom_models.json");
|
||||
if let Err(e) = catalog.save_custom_models(&custom_path) {
|
||||
tracing::warn!("Failed to persist custom models: {e}");
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "removed"})),
|
||||
)
|
||||
}
|
||||
|
||||
// ── A2A (Agent-to-Agent) Protocol Endpoints ─────────────────────────
|
||||
|
||||
/// GET /.well-known/agent.json — A2A Agent Card for the default agent.
|
||||
@@ -6912,7 +7117,7 @@ pub async fn update_agent_identity(
|
||||
// Agent Config Hot-Update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for patching agent config (name, description, prompt, identity).
|
||||
/// Request body for patching agent config (name, description, prompt, identity, model).
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PatchAgentConfigRequest {
|
||||
pub name: Option<String>,
|
||||
@@ -6924,6 +7129,10 @@ pub struct PatchAgentConfigRequest {
|
||||
pub archetype: Option<String>,
|
||||
pub vibe: Option<String>,
|
||||
pub greeting_style: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub api_key_env: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// PATCH /api/agents/{id}/config — Hot-update agent name, description, system prompt, and identity.
|
||||
@@ -7079,6 +7288,51 @@ pub async fn patch_agent_config(
|
||||
}
|
||||
}
|
||||
|
||||
// Update model/provider
|
||||
if let Some(ref new_model) = req.model {
|
||||
if !new_model.is_empty() {
|
||||
if let Some(ref new_provider) = req.provider {
|
||||
if !new_provider.is_empty() {
|
||||
if state
|
||||
.kernel
|
||||
.registry
|
||||
.update_model_and_provider(
|
||||
agent_id,
|
||||
new_model.clone(),
|
||||
new_provider.clone(),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
);
|
||||
}
|
||||
} else if state
|
||||
.kernel
|
||||
.registry
|
||||
.update_model(agent_id, new_model.clone())
|
||||
.is_err()
|
||||
{
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
);
|
||||
}
|
||||
} else if state
|
||||
.kernel
|
||||
.registry
|
||||
.update_model(agent_id, new_model.clone())
|
||||
.is_err()
|
||||
{
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Agent not found"})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "agent_id": id})),
|
||||
@@ -8703,3 +8957,531 @@ fn validate_webhook_token(headers: &axum::http::HeaderMap, token_env: &str) -> b
|
||||
}
|
||||
provided.as_bytes().ct_eq(expected.as_bytes()).into()
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// GitHub Copilot OAuth Device Flow
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// State for an in-progress device flow.
|
||||
struct CopilotFlowState {
|
||||
device_code: String,
|
||||
interval: u64,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// Active device flows, keyed by poll_id. Auto-expire after the flow's TTL.
|
||||
static COPILOT_FLOWS: LazyLock<DashMap<String, CopilotFlowState>> = LazyLock::new(DashMap::new);
|
||||
|
||||
/// POST /api/providers/github-copilot/oauth/start
|
||||
///
|
||||
/// Initiates a GitHub device flow for Copilot authentication.
|
||||
/// Returns a user code and verification URI that the user visits in their browser.
|
||||
pub async fn copilot_oauth_start() -> impl IntoResponse {
|
||||
// Clean up expired flows first
|
||||
COPILOT_FLOWS.retain(|_, state| state.expires_at > Instant::now());
|
||||
|
||||
match openfang_runtime::copilot_oauth::start_device_flow().await {
|
||||
Ok(resp) => {
|
||||
let poll_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
COPILOT_FLOWS.insert(
|
||||
poll_id.clone(),
|
||||
CopilotFlowState {
|
||||
device_code: resp.device_code,
|
||||
interval: resp.interval,
|
||||
expires_at: Instant::now()
|
||||
+ std::time::Duration::from_secs(resp.expires_in),
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"user_code": resp.user_code,
|
||||
"verification_uri": resp.verification_uri,
|
||||
"poll_id": poll_id,
|
||||
"expires_in": resp.expires_in,
|
||||
"interval": resp.interval,
|
||||
})),
|
||||
)
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/providers/github-copilot/oauth/poll/{poll_id}
|
||||
///
|
||||
/// Poll the status of a GitHub device flow.
|
||||
/// Returns `pending`, `complete`, `expired`, `denied`, or `error`.
|
||||
/// On `complete`, saves the token to secrets.env and sets GITHUB_TOKEN.
|
||||
pub async fn copilot_oauth_poll(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(poll_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let flow = match COPILOT_FLOWS.get(&poll_id) {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
if flow.expires_at <= Instant::now() {
|
||||
drop(flow);
|
||||
COPILOT_FLOWS.remove(&poll_id);
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "expired"})),
|
||||
);
|
||||
}
|
||||
|
||||
let device_code = flow.device_code.clone();
|
||||
drop(flow);
|
||||
|
||||
match openfang_runtime::copilot_oauth::poll_device_flow(&device_code).await {
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::Pending => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "pending"})),
|
||||
),
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::Complete { access_token } => {
|
||||
// Save to secrets.env
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")})),
|
||||
);
|
||||
}
|
||||
|
||||
// Set in current process
|
||||
std::env::set_var("GITHUB_TOKEN", access_token.as_str());
|
||||
|
||||
// Refresh auth detection
|
||||
state
|
||||
.kernel
|
||||
.model_catalog
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.detect_auth();
|
||||
|
||||
// Clean up flow state
|
||||
COPILOT_FLOWS.remove(&poll_id);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "complete"})),
|
||||
)
|
||||
}
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::SlowDown { new_interval } => {
|
||||
// Update interval
|
||||
if let Some(mut f) = COPILOT_FLOWS.get_mut(&poll_id) {
|
||||
f.interval = new_interval;
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "pending", "interval": new_interval})),
|
||||
)
|
||||
}
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::Expired => {
|
||||
COPILOT_FLOWS.remove(&poll_id);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "expired"})),
|
||||
)
|
||||
}
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::AccessDenied => {
|
||||
COPILOT_FLOWS.remove(&poll_id);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "denied"})),
|
||||
)
|
||||
}
|
||||
openfang_runtime::copilot_oauth::DeviceFlowStatus::Error(e) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "error", "error": e})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Communication (Comms) endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/comms/topology — Build agent topology graph from registry.
|
||||
pub async fn comms_topology(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
use openfang_types::comms::{EdgeKind, TopoEdge, TopoNode, Topology};
|
||||
|
||||
let agents = state.kernel.registry.list();
|
||||
|
||||
let nodes: Vec<TopoNode> = agents
|
||||
.iter()
|
||||
.map(|e| TopoNode {
|
||||
id: e.id.to_string(),
|
||||
name: e.name.clone(),
|
||||
state: format!("{:?}", e.state),
|
||||
model: e.manifest.model.model.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut edges: Vec<TopoEdge> = Vec::new();
|
||||
|
||||
// Parent-child edges from registry
|
||||
for agent in &agents {
|
||||
for child_id in &agent.children {
|
||||
edges.push(TopoEdge {
|
||||
from: agent.id.to_string(),
|
||||
to: child_id.to_string(),
|
||||
kind: EdgeKind::ParentChild,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Peer message edges from event bus history
|
||||
let events = state.kernel.event_bus.history(500).await;
|
||||
let mut peer_pairs = std::collections::HashSet::new();
|
||||
for event in &events {
|
||||
if let openfang_types::event::EventPayload::Message(_) = &event.payload {
|
||||
if let openfang_types::event::EventTarget::Agent(target_id) = &event.target {
|
||||
let from = event.source.to_string();
|
||||
let to = target_id.to_string();
|
||||
// Deduplicate: only one edge per pair, skip self-loops
|
||||
if from != to {
|
||||
let key = if from < to {
|
||||
(from.clone(), to.clone())
|
||||
} else {
|
||||
(to.clone(), from.clone())
|
||||
};
|
||||
if peer_pairs.insert(key) {
|
||||
edges.push(TopoEdge {
|
||||
from,
|
||||
to,
|
||||
kind: EdgeKind::Peer,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Json(serde_json::to_value(Topology { nodes, edges }).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Filter a kernel event into a CommsEvent, if it represents inter-agent communication.
|
||||
fn filter_to_comms_event(
|
||||
event: &openfang_types::event::Event,
|
||||
agents: &[openfang_types::agent::AgentEntry],
|
||||
) -> Option<openfang_types::comms::CommsEvent> {
|
||||
use openfang_types::comms::{CommsEvent, CommsEventKind};
|
||||
use openfang_types::event::{EventPayload, EventTarget, LifecycleEvent};
|
||||
|
||||
let resolve_name = |id: &str| -> String {
|
||||
agents
|
||||
.iter()
|
||||
.find(|a| a.id.to_string() == id)
|
||||
.map(|a| a.name.clone())
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
};
|
||||
|
||||
match &event.payload {
|
||||
EventPayload::Message(msg) => {
|
||||
let target_id = match &event.target {
|
||||
EventTarget::Agent(id) => id.to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(CommsEvent {
|
||||
id: event.id.to_string(),
|
||||
timestamp: event.timestamp.to_rfc3339(),
|
||||
kind: CommsEventKind::AgentMessage,
|
||||
source_id: event.source.to_string(),
|
||||
source_name: resolve_name(&event.source.to_string()),
|
||||
target_id: target_id.clone(),
|
||||
target_name: resolve_name(&target_id),
|
||||
detail: openfang_types::truncate_str(&msg.content, 200).to_string(),
|
||||
})
|
||||
}
|
||||
EventPayload::Lifecycle(lifecycle) => match lifecycle {
|
||||
LifecycleEvent::Spawned { agent_id, name } => Some(CommsEvent {
|
||||
id: event.id.to_string(),
|
||||
timestamp: event.timestamp.to_rfc3339(),
|
||||
kind: CommsEventKind::AgentSpawned,
|
||||
source_id: event.source.to_string(),
|
||||
source_name: resolve_name(&event.source.to_string()),
|
||||
target_id: agent_id.to_string(),
|
||||
target_name: name.clone(),
|
||||
detail: format!("Agent '{}' spawned", name),
|
||||
}),
|
||||
LifecycleEvent::Terminated { agent_id, reason } => Some(CommsEvent {
|
||||
id: event.id.to_string(),
|
||||
timestamp: event.timestamp.to_rfc3339(),
|
||||
kind: CommsEventKind::AgentTerminated,
|
||||
source_id: event.source.to_string(),
|
||||
source_name: resolve_name(&event.source.to_string()),
|
||||
target_id: agent_id.to_string(),
|
||||
target_name: resolve_name(&agent_id.to_string()),
|
||||
detail: format!("Terminated: {}", reason),
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an audit entry into a CommsEvent if it represents inter-agent activity.
|
||||
fn audit_to_comms_event(
|
||||
entry: &openfang_runtime::audit::AuditEntry,
|
||||
agents: &[openfang_types::agent::AgentEntry],
|
||||
) -> Option<openfang_types::comms::CommsEvent> {
|
||||
use openfang_types::comms::{CommsEvent, CommsEventKind};
|
||||
|
||||
let resolve_name = |id: &str| -> String {
|
||||
agents
|
||||
.iter()
|
||||
.find(|a| a.id.to_string() == id)
|
||||
.map(|a| a.name.clone())
|
||||
.unwrap_or_else(|| {
|
||||
if id.is_empty() || id == "system" {
|
||||
"system".to_string()
|
||||
} else {
|
||||
openfang_types::truncate_str(id, 12).to_string()
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
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(),
|
||||
),
|
||||
"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(),
|
||||
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(),
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
/// GET /api/comms/events — Return recent inter-agent communication events.
|
||||
///
|
||||
/// Sources from both the event bus (for lifecycle events with full context)
|
||||
/// and the audit log (for message/spawn/kill events that are always captured).
|
||||
pub async fn comms_events(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(100)
|
||||
.min(500);
|
||||
|
||||
let agents = state.kernel.registry.list();
|
||||
|
||||
// Primary source: event bus (has full source/target context)
|
||||
let bus_events = state.kernel.event_bus.history(500).await;
|
||||
let mut comms_events: Vec<openfang_types::comms::CommsEvent> = bus_events
|
||||
.iter()
|
||||
.filter_map(|e| filter_to_comms_event(e, &agents))
|
||||
.collect();
|
||||
|
||||
// Secondary source: audit log (always populated, wider coverage)
|
||||
let audit_entries = state.kernel.audit_log.recent(500);
|
||||
let seen_ids: std::collections::HashSet<String> =
|
||||
comms_events.iter().map(|e| e.id.clone()).collect();
|
||||
|
||||
for entry in audit_entries.iter().rev() {
|
||||
if let Some(ev) = audit_to_comms_event(entry, &agents) {
|
||||
if !seen_ids.contains(&ev.id) {
|
||||
comms_events.push(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (newest first)
|
||||
comms_events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
comms_events.truncate(limit);
|
||||
|
||||
Json(comms_events)
|
||||
}
|
||||
|
||||
/// GET /api/comms/events/stream — SSE stream of inter-agent communication events.
|
||||
///
|
||||
/// Polls the audit log every 500ms for new inter-agent events.
|
||||
pub async fn comms_events_stream(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<
|
||||
Result<axum::response::sse::Event, std::convert::Infallible>,
|
||||
>(256);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut last_seq: u64 = {
|
||||
let entries = state.kernel.audit_log.recent(1);
|
||||
entries.last().map(|e| e.seq).unwrap_or(0)
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
|
||||
let agents = state.kernel.registry.list();
|
||||
let entries = state.kernel.audit_log.recent(50);
|
||||
|
||||
for entry in &entries {
|
||||
if entry.seq <= last_seq {
|
||||
continue;
|
||||
}
|
||||
if let Some(comms_event) = audit_to_comms_event(entry, &agents) {
|
||||
let data = serde_json::to_string(&comms_event).unwrap_or_default();
|
||||
if tx.send(Ok(Event::default().data(data))).await.is_err() {
|
||||
return; // Client disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(last) = entries.last() {
|
||||
last_seq = last.seq;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let rx_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
Sse::new(rx_stream)
|
||||
.keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(std::time::Duration::from_secs(15))
|
||||
.text("ping"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// POST /api/comms/send — Send a message from one agent to another.
|
||||
pub async fn comms_send(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<openfang_types::comms::CommsSendRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// Validate from agent exists
|
||||
let from_id: openfang_types::agent::AgentId = match req.from_agent_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid from_agent_id"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
if state.kernel.registry.get(from_id).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Source agent not found"})),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate to agent exists
|
||||
let to_id: openfang_types::agent::AgentId = match req.to_agent_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid to_agent_id"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
if state.kernel.registry.get(to_id).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Target agent not found"})),
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Limit message size
|
||||
if req.message.len() > 64 * 1024 {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({"error": "Message too large (max 64KB)"})),
|
||||
);
|
||||
}
|
||||
|
||||
match state.kernel.send_message(to_id, &req.message).await {
|
||||
Ok(result) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"response": result.response,
|
||||
"input_tokens": result.total_usage.input_tokens,
|
||||
"output_tokens": result.total_usage.output_tokens,
|
||||
})),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Message delivery failed: {e}")})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/comms/task — Post a task to the agent task queue.
|
||||
pub async fn comms_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<openfang_types::comms::CommsTaskRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if req.title.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Title is required"})),
|
||||
);
|
||||
}
|
||||
|
||||
match state
|
||||
.kernel
|
||||
.memory
|
||||
.task_post(
|
||||
&req.title,
|
||||
&req.description,
|
||||
req.assigned_to.as_deref(),
|
||||
Some("ui-user"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(task_id) => (
|
||||
StatusCode::CREATED,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"task_id": task_id,
|
||||
})),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to post task: {e}")})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +335,11 @@ pub async fn build_router(
|
||||
"/api/hands/{hand_id}/install-deps",
|
||||
axum::routing::post(routes::install_hand_deps),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings)
|
||||
.put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
axum::routing::post(routes::pause_hand),
|
||||
@@ -377,6 +382,27 @@ pub async fn build_router(
|
||||
"/api/network/status",
|
||||
axum::routing::get(routes::network_status),
|
||||
)
|
||||
// Agent communication (Comms) endpoints
|
||||
.route(
|
||||
"/api/comms/topology",
|
||||
axum::routing::get(routes::comms_topology),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events",
|
||||
axum::routing::get(routes::comms_events),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events/stream",
|
||||
axum::routing::get(routes::comms_events_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/send",
|
||||
axum::routing::post(routes::comms_send),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/task",
|
||||
axum::routing::post(routes::comms_task),
|
||||
)
|
||||
// Tools endpoint
|
||||
.route("/api/tools", axum::routing::get(routes::list_tools))
|
||||
// Config endpoints
|
||||
@@ -450,8 +476,25 @@ pub async fn build_router(
|
||||
"/api/models/aliases",
|
||||
axum::routing::get(routes::list_aliases),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom",
|
||||
axum::routing::post(routes::add_custom_model),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom/{*id}",
|
||||
axum::routing::delete(routes::remove_custom_model),
|
||||
)
|
||||
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
|
||||
.route("/api/providers", axum::routing::get(routes::list_providers))
|
||||
// Copilot OAuth (must be before parametric {name} routes)
|
||||
.route(
|
||||
"/api/providers/github-copilot/oauth/start",
|
||||
axum::routing::post(routes::copilot_oauth_start),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/github-copilot/oauth/poll/{poll_id}",
|
||||
axum::routing::get(routes::copilot_oauth_poll),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{name}/key",
|
||||
axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key),
|
||||
|
||||
@@ -102,9 +102,15 @@ impl StreamChunker {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Forced break at max_chunk_chars
|
||||
// Priority 4: Forced break at max_chunk_chars (char-boundary safe)
|
||||
if self.buffer.len() >= self.max_chunk_chars {
|
||||
let break_at = self.max_chunk_chars;
|
||||
let mut break_at = self.max_chunk_chars;
|
||||
while break_at > 0 && !self.buffer.is_char_boundary(break_at) {
|
||||
break_at -= 1;
|
||||
}
|
||||
if break_at == 0 {
|
||||
break_at = self.buffer.len();
|
||||
}
|
||||
let chunk = self.buffer[..break_at].to_string();
|
||||
self.buffer = self.buffer[break_at..].to_string();
|
||||
return Some(chunk);
|
||||
|
||||
@@ -123,6 +123,8 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/wizard.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/approvals.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/comms.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
|
||||
@@ -3072,4 +3072,16 @@ mark.search-highlight {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.flex-col { flex-direction: column; }
|
||||
/* Comms page */
|
||||
.comms-topo-tree { padding: 4px 0 4px 8px; }
|
||||
.comms-topo-child { padding: 0 0 0 20px; display: flex; align-items: center; gap: 4px; }
|
||||
.comms-topo-branch { color: var(--text-dim); font-family: var(--font-mono); white-space: pre; }
|
||||
.comms-topo-node { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
|
||||
.comms-event-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; transition: background var(--transition-fast);
|
||||
}
|
||||
.comms-event-row:hover { background: var(--bg-hover); }
|
||||
.comms-event-time { min-width: 50px; text-align: right; }
|
||||
.comms-event-detail { margin-left: auto; }
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
|
||||
<span class="nav-label">Approvals</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
||||
<span class="nav-label">Comms</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -2884,6 +2888,21 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<template x-if="p.auth_status !== 'configured' && p.api_key_env">
|
||||
<div class="text-xs text-dim mt-2">Or set <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px" x-text="p.api_key_env"></code> in your environment and restart</div>
|
||||
</template>
|
||||
<!-- Copilot OAuth button -->
|
||||
<template x-if="p.id === 'github-copilot' && p.auth_status !== 'configured'">
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" @click="startCopilotOAuth()" :disabled="copilotOAuth.polling" x-show="!copilotOAuth.userCode">Login with GitHub</button>
|
||||
<div x-show="copilotOAuth.userCode" class="mt-2">
|
||||
<div class="text-sm">Visit <a :href="copilotOAuth.verificationUri" target="_blank" x-text="copilotOAuth.verificationUri" style="color:var(--accent-light)"></a> and enter:</div>
|
||||
<div style="font-size:24px;font-weight:bold;letter-spacing:4px;margin:8px 0;color:var(--accent-light)" x-text="copilotOAuth.userCode"></div>
|
||||
<div class="text-xs text-dim"><span class="spinner" style="width:10px;height:10px;border-width:2px;display:inline-block;vertical-align:middle"></span> Waiting for authorization...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Claude Code install hint -->
|
||||
<template x-if="p.id === 'claude-code' && p.auth_status !== 'configured'">
|
||||
<div class="mt-2 text-xs text-dim">Install: <code style="color:var(--accent-light);background:var(--bg);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
||||
</template>
|
||||
<!-- Actions for configured providers -->
|
||||
<template x-if="p.auth_status === 'configured'">
|
||||
<div class="flex gap-2 mt-2">
|
||||
@@ -2943,6 +2962,31 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<option :value="t" x-text="t"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" @click="showCustomModelForm = !showCustomModelForm" x-text="showCustomModelForm ? 'Cancel' : '+ Custom Model'"></button>
|
||||
</div>
|
||||
<!-- Custom model form -->
|
||||
<div x-show="showCustomModelForm" class="info-card mb-4" style="border:1px solid var(--accent,#7c3aed)">
|
||||
<h4 style="margin-top:0">Add Custom Model</h4>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<div>
|
||||
<label class="text-xs text-dim">Model ID (required)</label>
|
||||
<input class="form-input" x-model="customModelId" placeholder="e.g. my-org/my-model">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Provider</label>
|
||||
<input class="form-input" x-model="customModelProvider" placeholder="openrouter">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Context Window</label>
|
||||
<input class="form-input" type="number" x-model.number="customModelContext" placeholder="128000">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Max Output Tokens</label>
|
||||
<input class="form-input" type="number" x-model.number="customModelMaxOutput" placeholder="8192">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="addCustomModel()" :disabled="!customModelId.trim()">Add Model</button>
|
||||
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
||||
<div class="table-wrap" x-show="filteredModels.length">
|
||||
@@ -3980,6 +4024,176 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Page: Comms -->
|
||||
<template x-if="page === 'comms'">
|
||||
<div x-data="commsPage" x-init="loadData()" @page-leave.window="stopSSE()">
|
||||
<div class="page-header">
|
||||
<h2>Agent Comms</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-primary btn-sm" @click="openSendModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
|
||||
Send Message
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="openTaskModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
|
||||
Post Task
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="loadData()" title="Refresh">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-body">
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" style="animation:fadeIn 0.2s">
|
||||
<div class="card mb-4"><div class="skeleton skeleton-text" style="width:160px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:120px"></div></div>
|
||||
<div class="card"><div class="skeleton skeleton-text" style="width:120px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:200px"></div></div>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<div x-show="!loading && loadError" class="error-state" style="animation:fadeIn 0.3s">
|
||||
<h3 style="color:var(--error)">Connection Error</h3>
|
||||
<p class="text-xs text-dim" x-text="loadError"></p>
|
||||
<button class="btn btn-primary btn-sm" @click="loadData()" style="margin-top:8px">Retry</button>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div x-show="!loading && !loadError" style="animation:fadeIn 0.3s">
|
||||
<!-- Topology -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>
|
||||
Agent Topology
|
||||
<span class="badge badge-dim" style="margin-left:8px;font-weight:400" x-text="topology.nodes.length + ' agents'"></span>
|
||||
</div>
|
||||
<div style="padding:8px 0;font-family:var(--font-mono);font-size:12px;line-height:1.8">
|
||||
<template x-if="topology.nodes.length === 0">
|
||||
<div class="text-dim" style="text-align:center;padding:24px">No agents running</div>
|
||||
</template>
|
||||
<template x-for="root in rootNodes()" :key="root.id">
|
||||
<div class="comms-topo-tree">
|
||||
<div class="comms-topo-node" :title="root.id">
|
||||
<span :class="stateBadgeClass(root.state)" style="font-size:10px;padding:1px 6px" x-text="root.state"></span>
|
||||
<strong x-text="root.name" style="margin:0 4px"></strong>
|
||||
<span class="text-dim" x-text="root.model"></span>
|
||||
<template x-for="peer in peersOf(root.id)" :key="peer.id">
|
||||
<span class="text-dim" style="margin-left:8px" x-text="'\u2194 ' + peer.name"></span>
|
||||
</template>
|
||||
</div>
|
||||
<template x-for="(child, ci) in childrenOf(root.id)" :key="child.id">
|
||||
<div class="comms-topo-child">
|
||||
<span class="comms-topo-branch" x-text="ci < childrenOf(root.id).length - 1 ? '\u251c\u2500\u2500 ' : '\u2514\u2500\u2500 '"></span>
|
||||
<span :class="stateBadgeClass(child.state)" style="font-size:10px;padding:1px 6px" x-text="child.state"></span>
|
||||
<strong x-text="child.name" style="margin:0 4px"></strong>
|
||||
<span class="text-dim" x-text="child.model"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Event Feed -->
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
Live Event Feed
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-success" style="font-size:9px;padding:2px 6px;animation:pulse-ring 2s infinite">LIVE</span>
|
||||
<span class="text-xs text-dim" x-text="events.length + ' events'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-height:400px;overflow-y:auto">
|
||||
<template x-if="events.length === 0">
|
||||
<div class="text-dim" style="text-align:center;padding:24px">No inter-agent events yet</div>
|
||||
</template>
|
||||
<template x-for="ev in events" :key="ev.id">
|
||||
<div class="comms-event-row">
|
||||
<span class="comms-event-time text-xs text-dim" x-text="timeAgo(ev.timestamp)"></span>
|
||||
<span :class="eventBadgeClass(ev.kind)" style="font-size:10px;padding:1px 6px;min-width:70px;text-align:center" x-text="eventLabel(ev.kind)"></span>
|
||||
<span style="font-weight:600;font-size:12px" x-text="ev.source_name"></span>
|
||||
<span class="text-dim" x-show="ev.target_name" x-text="'\u2192 ' + ev.target_name"></span>
|
||||
<span class="comms-event-detail text-dim text-xs" x-text="ev.detail" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Modal -->
|
||||
<div x-show="showSendModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" x-transition>
|
||||
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
||||
<div class="card-header">Send Agent Message</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">From Agent</label>
|
||||
<select x-model="sendFrom" class="input" style="width:100%">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">To Agent</label>
|
||||
<select x-model="sendTo" class="input" style="width:100%">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Message</label>
|
||||
<textarea x-model="sendMsg" class="input" rows="3" placeholder="Type a message..." style="width:100%;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2" style="justify-content:flex-end">
|
||||
<button class="btn btn-ghost btn-sm" @click="showSendModal=false">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitSend()" :disabled="sendLoading || !sendFrom || !sendTo || !sendMsg.trim()">
|
||||
<span x-show="sendLoading">Sending...</span>
|
||||
<span x-show="!sendLoading">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Task Modal -->
|
||||
<div x-show="showTaskModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showTaskModal=false" x-transition>
|
||||
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
||||
<div class="card-header">Post Task</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Title</label>
|
||||
<input type="text" x-model="taskTitle" class="input" placeholder="Task title..." style="width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Description</label>
|
||||
<textarea x-model="taskDesc" class="input" rows="3" placeholder="Task description..." style="width:100%;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Assign To (optional)</label>
|
||||
<select x-model="taskAssign" class="input" style="width:100%">
|
||||
<option value="">Unassigned</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2" style="justify-content:flex-end">
|
||||
<button class="btn btn-ghost btn-sm" @click="showTaskModal=false">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitTask()" :disabled="taskLoading || !taskTitle.trim()">
|
||||
<span x-show="taskLoading">Posting...</span>
|
||||
<span x-show="!taskLoading">Post Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Page: Setup Wizard -->
|
||||
<template x-if="page === 'wizard'">
|
||||
<div x-data="wizardPage">
|
||||
|
||||
@@ -155,10 +155,19 @@ document.addEventListener('alpine:init', function() {
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
await OpenFangAPI.get('/api/providers');
|
||||
// Use a protected endpoint (not in the public allowlist) to detect
|
||||
// whether the server requires an API key.
|
||||
await OpenFangAPI.get('/api/tools');
|
||||
this.showAuthPrompt = false;
|
||||
} catch(e) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0)) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
|
||||
// Only show prompt if we don't already have a saved key
|
||||
var saved = localStorage.getItem('openfang-api-key');
|
||||
if (saved) {
|
||||
// Saved key might be stale — clear it and show prompt
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
this.showAuthPrompt = true;
|
||||
}
|
||||
}
|
||||
@@ -209,7 +218,7 @@ function app() {
|
||||
});
|
||||
|
||||
// Hash routing
|
||||
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
|
||||
var pageRedirects = {
|
||||
'chat': 'agents',
|
||||
'templates': 'agents',
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// OpenFang Comms Page — Agent topology & inter-agent communication feed
|
||||
'use strict';
|
||||
|
||||
function commsPage() {
|
||||
return {
|
||||
topology: { nodes: [], edges: [] },
|
||||
events: [],
|
||||
loading: true,
|
||||
loadError: '',
|
||||
sseSource: null,
|
||||
showSendModal: false,
|
||||
showTaskModal: false,
|
||||
sendFrom: '',
|
||||
sendTo: '',
|
||||
sendMsg: '',
|
||||
sendLoading: false,
|
||||
taskTitle: '',
|
||||
taskDesc: '',
|
||||
taskAssign: '',
|
||||
taskLoading: false,
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/comms/topology'),
|
||||
OpenFangAPI.get('/api/comms/events?limit=200')
|
||||
]);
|
||||
this.topology = results[0] || { nodes: [], edges: [] };
|
||||
this.events = results[1] || [];
|
||||
this.startSSE();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load comms data.';
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
startSSE() {
|
||||
if (this.sseSource) this.sseSource.close();
|
||||
var self = this;
|
||||
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
|
||||
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
|
||||
this.sseSource = new EventSource(url);
|
||||
this.sseSource.onmessage = function(ev) {
|
||||
if (ev.data === 'ping') return;
|
||||
try {
|
||||
var event = JSON.parse(ev.data);
|
||||
self.events.unshift(event);
|
||||
if (self.events.length > 200) self.events.length = 200;
|
||||
// Refresh topology on spawn/terminate events
|
||||
if (event.kind === 'agent_spawned' || event.kind === 'agent_terminated') {
|
||||
self.refreshTopology();
|
||||
}
|
||||
} catch(e) { /* ignore parse errors */ }
|
||||
};
|
||||
},
|
||||
|
||||
stopSSE() {
|
||||
if (this.sseSource) {
|
||||
this.sseSource.close();
|
||||
this.sseSource = null;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshTopology() {
|
||||
try {
|
||||
this.topology = await OpenFangAPI.get('/api/comms/topology');
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
rootNodes() {
|
||||
var childIds = {};
|
||||
var self = this;
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child') childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return !childIds[n.id]; });
|
||||
},
|
||||
|
||||
childrenOf(id) {
|
||||
var childIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child' && e.from === id) childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return childIds[n.id]; });
|
||||
},
|
||||
|
||||
peersOf(id) {
|
||||
var peerIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'peer') {
|
||||
if (e.from === id) peerIds[e.to] = true;
|
||||
if (e.to === id) peerIds[e.from] = true;
|
||||
}
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return peerIds[n.id]; });
|
||||
},
|
||||
|
||||
stateBadgeClass(state) {
|
||||
switch(state) {
|
||||
case 'Running': return 'badge badge-success';
|
||||
case 'Suspended': return 'badge badge-warning';
|
||||
case 'Terminated': case 'Crashed': return 'badge badge-danger';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventBadgeClass(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'badge badge-info';
|
||||
case 'agent_spawned': return 'badge badge-success';
|
||||
case 'agent_terminated': return 'badge badge-danger';
|
||||
case 'task_posted': return 'badge badge-warning';
|
||||
case 'task_claimed': return 'badge badge-info';
|
||||
case 'task_completed': return 'badge badge-success';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventIcon(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return '\u2709';
|
||||
case 'agent_spawned': return '+';
|
||||
case 'agent_terminated': return '\u2715';
|
||||
case 'task_posted': return '\u2691';
|
||||
case 'task_claimed': return '\u2690';
|
||||
case 'task_completed': return '\u2713';
|
||||
default: return '\u2022';
|
||||
}
|
||||
},
|
||||
|
||||
eventLabel(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'Message';
|
||||
case 'agent_spawned': return 'Spawned';
|
||||
case 'agent_terminated': return 'Terminated';
|
||||
case 'task_posted': return 'Task Posted';
|
||||
case 'task_claimed': return 'Task Claimed';
|
||||
case 'task_completed': return 'Task Done';
|
||||
default: return kind;
|
||||
}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
var d = new Date(dateStr);
|
||||
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (secs < 60) return secs + 's ago';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
|
||||
return Math.floor(secs / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
openSendModal() {
|
||||
this.sendFrom = '';
|
||||
this.sendTo = '';
|
||||
this.sendMsg = '';
|
||||
this.showSendModal = true;
|
||||
},
|
||||
|
||||
async submitSend() {
|
||||
if (!this.sendFrom || !this.sendTo || !this.sendMsg.trim()) return;
|
||||
this.sendLoading = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/comms/send', {
|
||||
from_agent_id: this.sendFrom,
|
||||
to_agent_id: this.sendTo,
|
||||
message: this.sendMsg
|
||||
});
|
||||
OpenFangToast.success('Message sent');
|
||||
this.showSendModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Send failed');
|
||||
}
|
||||
this.sendLoading = false;
|
||||
},
|
||||
|
||||
openTaskModal() {
|
||||
this.taskTitle = '';
|
||||
this.taskDesc = '';
|
||||
this.taskAssign = '';
|
||||
this.showTaskModal = true;
|
||||
},
|
||||
|
||||
async submitTask() {
|
||||
if (!this.taskTitle.trim()) return;
|
||||
this.taskLoading = true;
|
||||
try {
|
||||
var body = { title: this.taskTitle, description: this.taskDesc };
|
||||
if (this.taskAssign) body.assigned_to = this.taskAssign;
|
||||
await OpenFangAPI.post('/api/comms/task', body);
|
||||
OpenFangToast.success('Task posted');
|
||||
this.showTaskModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Task failed');
|
||||
}
|
||||
this.taskLoading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,11 +14,18 @@ function settingsPage() {
|
||||
modelSearch: '',
|
||||
modelProviderFilter: '',
|
||||
modelTierFilter: '',
|
||||
showCustomModelForm: false,
|
||||
customModelId: '',
|
||||
customModelProvider: 'openrouter',
|
||||
customModelContext: 128000,
|
||||
customModelMaxOutput: 8192,
|
||||
customModelStatus: '',
|
||||
providerKeyInputs: {},
|
||||
providerUrlInputs: {},
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -212,8 +219,13 @@ function settingsPage() {
|
||||
this.providers = data.providers || [];
|
||||
for (var i = 0; i < this.providers.length; i++) {
|
||||
var p = this.providers[i];
|
||||
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url;
|
||||
if (p.is_local) {
|
||||
if (!this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url || '';
|
||||
}
|
||||
if (this.providerUrlSaving[p.id] === undefined) {
|
||||
this.providerUrlSaving[p.id] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
@@ -226,6 +238,26 @@ function settingsPage() {
|
||||
} catch(e) { this.models = []; }
|
||||
},
|
||||
|
||||
async addCustomModel() {
|
||||
var id = this.customModelId.trim();
|
||||
if (!id) return;
|
||||
this.customModelStatus = 'Adding...';
|
||||
try {
|
||||
await OpenFangAPI.post('/api/models/custom', {
|
||||
id: id,
|
||||
provider: this.customModelProvider || 'openrouter',
|
||||
context_window: this.customModelContext || 128000,
|
||||
max_output_tokens: this.customModelMaxOutput || 8192,
|
||||
});
|
||||
this.customModelStatus = 'Added!';
|
||||
this.customModelId = '';
|
||||
this.showCustomModelForm = false;
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigSchema() {
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
@@ -368,6 +400,54 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
async startCopilotOAuth() {
|
||||
this.copilotOAuth.polling = true;
|
||||
this.copilotOAuth.userCode = '';
|
||||
try {
|
||||
var resp = await OpenFangAPI.post('/api/providers/github-copilot/oauth/start', {});
|
||||
this.copilotOAuth.userCode = resp.user_code;
|
||||
this.copilotOAuth.verificationUri = resp.verification_uri;
|
||||
this.copilotOAuth.pollId = resp.poll_id;
|
||||
this.copilotOAuth.interval = resp.interval || 5;
|
||||
window.open(resp.verification_uri, '_blank');
|
||||
this.pollCopilotOAuth();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to start Copilot login: ' + e.message);
|
||||
this.copilotOAuth.polling = false;
|
||||
}
|
||||
},
|
||||
|
||||
pollCopilotOAuth() {
|
||||
var self = this;
|
||||
setTimeout(async function() {
|
||||
if (!self.copilotOAuth.pollId) return;
|
||||
try {
|
||||
var resp = await OpenFangAPI.get('/api/providers/github-copilot/oauth/poll/' + self.copilotOAuth.pollId);
|
||||
if (resp.status === 'complete') {
|
||||
OpenFangToast.success('GitHub Copilot authenticated successfully!');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
await self.loadProviders();
|
||||
await self.loadModels();
|
||||
} else if (resp.status === 'pending') {
|
||||
if (resp.interval) self.copilotOAuth.interval = resp.interval;
|
||||
self.pollCopilotOAuth();
|
||||
} else if (resp.status === 'expired') {
|
||||
OpenFangToast.error('Device code expired. Please try again.');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
} else if (resp.status === 'denied') {
|
||||
OpenFangToast.error('Access denied by user.');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
} else {
|
||||
OpenFangToast.error('OAuth error: ' + (resp.error || resp.status));
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Poll error: ' + e.message);
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
}
|
||||
}, self.copilotOAuth.interval * 1000);
|
||||
},
|
||||
|
||||
async testProvider(provider) {
|
||||
this.providerTesting[provider.id] = true;
|
||||
this.providerTestResults[provider.id] = null;
|
||||
|
||||
@@ -132,6 +132,9 @@ enum Commands {
|
||||
/// Manage channel integrations (setup, test, enable, disable) [*].
|
||||
#[command(subcommand)]
|
||||
Channel(ChannelCommands),
|
||||
/// Manage hands (list, activate, deactivate, info) [*].
|
||||
#[command(subcommand)]
|
||||
Hand(HandCommands),
|
||||
/// Show or edit configuration (show, edit, get, set, keys) [*].
|
||||
#[command(subcommand)]
|
||||
Config(ConfigCommands),
|
||||
@@ -371,6 +374,29 @@ enum ChannelCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum HandCommands {
|
||||
/// List all available hands.
|
||||
List,
|
||||
/// Show currently active hand instances.
|
||||
Active,
|
||||
/// Activate a hand by ID.
|
||||
Activate {
|
||||
/// Hand ID (e.g. "clip", "lead", "researcher").
|
||||
id: String,
|
||||
},
|
||||
/// Deactivate an active hand instance.
|
||||
Deactivate {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Show detailed info about a hand.
|
||||
Info {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ConfigCommands {
|
||||
/// Show the current configuration.
|
||||
@@ -834,6 +860,13 @@ fn main() {
|
||||
ChannelCommands::Enable { channel } => cmd_channel_toggle(&channel, true),
|
||||
ChannelCommands::Disable { channel } => cmd_channel_toggle(&channel, false),
|
||||
},
|
||||
Some(Commands::Hand(sub)) => match sub {
|
||||
HandCommands::List => cmd_hand_list(),
|
||||
HandCommands::Active => cmd_hand_active(),
|
||||
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
||||
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
||||
HandCommands::Info { id } => cmd_hand_info(&id),
|
||||
},
|
||||
Some(Commands::Config(sub)) => match sub {
|
||||
ConfigCommands::Show => cmd_config_show(),
|
||||
ConfigCommands::Edit => cmd_config_edit(),
|
||||
@@ -1248,6 +1281,7 @@ fn write_config_if_missing(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
|
||||
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
||||
api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
@@ -1913,6 +1947,7 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
let default_config = r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
|
||||
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
||||
api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
@@ -3698,6 +3733,168 @@ fn cmd_channel_toggle(channel: &str, enable: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hand commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cmd_hand_list() {
|
||||
let base = require_daemon("hand list");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/hands")).send());
|
||||
// API returns {"hands": [...]} or a bare array
|
||||
let arr_val;
|
||||
if let Some(arr) = body.get("hands").and_then(|v| v.as_array()) {
|
||||
arr_val = arr.clone();
|
||||
} else if let Some(arr) = body.as_array() {
|
||||
arr_val = arr.clone();
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(arr) = Some(&arr_val) {
|
||||
if arr.is_empty() {
|
||||
println!("No hands available.");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<14} {:<20} {:<10} DESCRIPTION",
|
||||
"ID", "NAME", "CATEGORY"
|
||||
);
|
||||
println!("{}", "-".repeat(72));
|
||||
for h in arr {
|
||||
println!(
|
||||
"{:<14} {:<20} {:<10} {}",
|
||||
h["id"].as_str().unwrap_or("?"),
|
||||
h["name"].as_str().unwrap_or("?"),
|
||||
h["category"].as_str().unwrap_or("?"),
|
||||
h["description"].as_str().unwrap_or("").chars().take(40).collect::<String>(),
|
||||
);
|
||||
}
|
||||
println!("\nUse `openfang hand activate <id>` to activate a hand.");
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_active() {
|
||||
let base = require_daemon("hand active");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/hands/active")).send());
|
||||
// API returns {"instances": [...]} or bare array
|
||||
let arr = body
|
||||
.get("instances")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if arr.is_empty() {
|
||||
println!("No active hands.");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<38} {:<14} {:<10} AGENT",
|
||||
"INSTANCE", "HAND", "STATUS"
|
||||
);
|
||||
println!("{}", "-".repeat(72));
|
||||
for i in &arr {
|
||||
println!(
|
||||
"{:<38} {:<14} {:<10} {}",
|
||||
i["instance_id"].as_str().unwrap_or("?"),
|
||||
i["hand_id"].as_str().unwrap_or("?"),
|
||||
i["status"].as_str().unwrap_or("?"),
|
||||
i["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_activate(id: &str) {
|
||||
let base = require_daemon("hand activate");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/activate"))
|
||||
.header("content-type", "application/json")
|
||||
.body("{}")
|
||||
.send(),
|
||||
);
|
||||
if body.get("instance_id").is_some() {
|
||||
println!(
|
||||
"Hand '{}' activated (instance: {}, agent: {})",
|
||||
id,
|
||||
body["instance_id"].as_str().unwrap_or("?"),
|
||||
body["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to activate hand '{}': {}",
|
||||
id,
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_deactivate(id: &str) {
|
||||
let base = require_daemon("hand deactivate");
|
||||
let client = daemon_client();
|
||||
// First find the instance ID for this hand
|
||||
let active = daemon_json(client.get(format!("{base}/api/hands/active")).send());
|
||||
let arr = active
|
||||
.get("instances")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| active.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let instance_id = arr.iter().find_map(|i| {
|
||||
if i["hand_id"].as_str() == Some(id) {
|
||||
i["instance_id"].as_str().map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
match instance_id {
|
||||
Some(iid) => {
|
||||
let body = daemon_json(
|
||||
client
|
||||
.delete(format!("{base}/api/hands/instances/{iid}"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("status").is_some() {
|
||||
println!("Hand '{id}' deactivated.");
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
eprintln!("No active instance found for hand '{id}'.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_info(id: &str) {
|
||||
let base = require_daemon("hand info");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
|
||||
if body.get("error").is_some() {
|
||||
eprintln!(
|
||||
"Hand not found: {}",
|
||||
body["error"].as_str().unwrap_or(id)
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / API key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -189,6 +189,17 @@ pub enum AppEvent {
|
||||
AgentSkillsUpdated(String),
|
||||
/// Agent MCP servers updated.
|
||||
AgentMcpServersUpdated(String),
|
||||
/// Comms topology loaded.
|
||||
CommsTopologyLoaded {
|
||||
nodes: Vec<super::screens::comms::CommsNode>,
|
||||
edges: Vec<super::screens::comms::CommsEdge>,
|
||||
},
|
||||
/// Comms events loaded.
|
||||
CommsEventsLoaded(Vec<super::screens::comms::CommsEventItem>),
|
||||
/// Comms send result.
|
||||
CommsSendResult(String),
|
||||
/// Comms task post result.
|
||||
CommsTaskResult(String),
|
||||
}
|
||||
|
||||
/// Spawn the crossterm polling + tick thread. Returns sender + receiver.
|
||||
@@ -2592,3 +2603,176 @@ pub fn spawn_reconnect_extension(backend: BackendRef, id: String, tx: mpsc::Send
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch comms topology + events.
|
||||
pub fn spawn_fetch_comms(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
use super::screens::comms::{CommsEdge, CommsEventItem, CommsNode};
|
||||
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
// Fetch topology
|
||||
if let Ok(resp) = client.get(format!("{base_url}/api/comms/topology")).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let nodes: Vec<CommsNode> = body["nodes"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|n| CommsNode {
|
||||
id: n["id"].as_str().unwrap_or("").to_string(),
|
||||
name: n["name"].as_str().unwrap_or("").to_string(),
|
||||
state: n["state"].as_str().unwrap_or("").to_string(),
|
||||
model: n["model"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let edges: Vec<CommsEdge> = body["edges"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|e| CommsEdge {
|
||||
from: e["from"].as_str().unwrap_or("").to_string(),
|
||||
to: e["to"].as_str().unwrap_or("").to_string(),
|
||||
kind: e["kind"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = tx.send(AppEvent::CommsTopologyLoaded { nodes, edges });
|
||||
}
|
||||
}
|
||||
// Fetch events
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/comms/events?limit=100"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let events: Vec<CommsEventItem> = body
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|e| CommsEventItem {
|
||||
id: e["id"].as_str().unwrap_or("").to_string(),
|
||||
timestamp: e["timestamp"].as_str().unwrap_or("").to_string(),
|
||||
kind: e["kind"].as_str().unwrap_or("").to_string(),
|
||||
source_name: e["source_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
target_name: e["target_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
detail: e["detail"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = tx.send(AppEvent::CommsEventsLoaded(events));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsTopologyLoaded {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
});
|
||||
let _ = tx.send(AppEvent::CommsEventsLoaded(Vec::new()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Send a message between agents via comms endpoint.
|
||||
pub fn spawn_comms_send(
|
||||
backend: BackendRef,
|
||||
from: String,
|
||||
to: String,
|
||||
msg: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let body = serde_json::json!({
|
||||
"from_agent_id": from,
|
||||
"to_agent_id": to,
|
||||
"message": msg,
|
||||
});
|
||||
match client
|
||||
.post(format!("{base_url}/api/comms/send"))
|
||||
.json(&body)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
let _ = tx.send(AppEvent::CommsSendResult("Message sent".to_string()));
|
||||
} else {
|
||||
let err = resp
|
||||
.json::<serde_json::Value>()
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "Send failed".to_string());
|
||||
let _ = tx.send(AppEvent::CommsSendResult(err));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(AppEvent::CommsSendResult(format!("Error: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsSendResult(
|
||||
"Send not supported in-process".to_string(),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Post a task via comms endpoint.
|
||||
pub fn spawn_comms_task(
|
||||
backend: BackendRef,
|
||||
title: String,
|
||||
desc: String,
|
||||
assign: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let mut body = serde_json::json!({
|
||||
"title": title,
|
||||
"description": desc,
|
||||
});
|
||||
if !assign.is_empty() {
|
||||
body["assigned_to"] = serde_json::Value::String(assign);
|
||||
}
|
||||
match client
|
||||
.post(format!("{base_url}/api/comms/task"))
|
||||
.json(&body)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult("Task posted".to_string()));
|
||||
} else {
|
||||
let err = resp
|
||||
.json::<serde_json::Value>()
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "Post failed".to_string());
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(err));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(format!("Error: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(
|
||||
"Task post not supported in-process".to_string(),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use screens::{
|
||||
agents, audit, channels, chat, dashboard, extensions, hands, logs, memory, peers, security,
|
||||
sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
|
||||
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{mpsc, Arc};
|
||||
@@ -53,6 +53,7 @@ enum Tab {
|
||||
Extensions,
|
||||
Templates,
|
||||
Peers,
|
||||
Comms,
|
||||
Security,
|
||||
Audit,
|
||||
Usage,
|
||||
@@ -74,6 +75,7 @@ const TABS: &[Tab] = &[
|
||||
Tab::Extensions,
|
||||
Tab::Templates,
|
||||
Tab::Peers,
|
||||
Tab::Comms,
|
||||
Tab::Security,
|
||||
Tab::Audit,
|
||||
Tab::Usage,
|
||||
@@ -97,6 +99,7 @@ impl Tab {
|
||||
Tab::Extensions => "Extensions",
|
||||
Tab::Templates => "Templates",
|
||||
Tab::Peers => "Peers",
|
||||
Tab::Comms => "Comms",
|
||||
Tab::Security => "Security",
|
||||
Tab::Audit => "Audit",
|
||||
Tab::Usage => "Usage",
|
||||
@@ -169,6 +172,7 @@ struct App {
|
||||
usage: usage::UsageState,
|
||||
settings: settings::SettingsState,
|
||||
peers: peers::PeersState,
|
||||
comms: comms::CommsState,
|
||||
logs: logs::LogsState,
|
||||
|
||||
kernel_booting: bool,
|
||||
@@ -207,6 +211,7 @@ impl App {
|
||||
usage: usage::UsageState::new(),
|
||||
settings: settings::SettingsState::new(),
|
||||
peers: peers::PeersState::new(),
|
||||
comms: comms::CommsState::new(),
|
||||
logs: logs::LogsState::new(),
|
||||
kernel_booting: false,
|
||||
kernel_boot_error: None,
|
||||
@@ -504,6 +509,26 @@ impl App {
|
||||
}
|
||||
self.peers.loading = false;
|
||||
}
|
||||
AppEvent::CommsTopologyLoaded { nodes, edges } => {
|
||||
self.comms.nodes = nodes;
|
||||
self.comms.edges = edges;
|
||||
self.comms.loading = false;
|
||||
}
|
||||
AppEvent::CommsEventsLoaded(events) => {
|
||||
self.comms.events = events;
|
||||
if !self.comms.events.is_empty()
|
||||
&& self.comms.event_list_state.selected().is_none()
|
||||
{
|
||||
self.comms.event_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
AppEvent::CommsSendResult(msg) => {
|
||||
self.comms.status_msg = msg;
|
||||
self.refresh_comms();
|
||||
}
|
||||
AppEvent::CommsTaskResult(msg) => {
|
||||
self.comms.status_msg = msg;
|
||||
}
|
||||
AppEvent::LogsLoaded(entries) => {
|
||||
self.logs.entries = entries;
|
||||
self.logs.refilter();
|
||||
@@ -845,6 +870,10 @@ impl App {
|
||||
let action = self.peers.handle_key(key);
|
||||
self.handle_peers_action(action);
|
||||
}
|
||||
Tab::Comms => {
|
||||
let action = self.comms.handle_key(key);
|
||||
self.handle_comms_action(action);
|
||||
}
|
||||
Tab::Logs => {
|
||||
let action = self.logs.handle_key(key);
|
||||
self.handle_logs_action(action);
|
||||
@@ -876,6 +905,7 @@ impl App {
|
||||
self.usage.tick();
|
||||
self.settings.tick();
|
||||
self.peers.tick();
|
||||
self.comms.tick();
|
||||
self.logs.tick();
|
||||
|
||||
// Auto-poll for active tabs
|
||||
@@ -883,6 +913,7 @@ impl App {
|
||||
match self.active_tab {
|
||||
Tab::Logs if self.logs.should_poll() => self.refresh_logs(),
|
||||
Tab::Peers if self.peers.should_poll() => self.refresh_peers(),
|
||||
Tab::Comms if self.comms.should_poll() => self.refresh_comms(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -932,6 +963,7 @@ impl App {
|
||||
Tab::Usage => self.refresh_usage(),
|
||||
Tab::Settings => self.refresh_settings_providers(),
|
||||
Tab::Peers => self.refresh_peers(),
|
||||
Tab::Comms => self.refresh_comms(),
|
||||
Tab::Logs => self.refresh_logs(),
|
||||
Tab::Chat => {} // Chat doesn't need refresh on enter
|
||||
}
|
||||
@@ -1089,6 +1121,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_comms(&mut self) {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
self.comms.loading = true;
|
||||
event::spawn_fetch_comms(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_logs(&mut self) {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
self.logs.loading = true;
|
||||
@@ -1660,6 +1699,27 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_comms_action(&mut self, action: comms::CommsAction) {
|
||||
match action {
|
||||
comms::CommsAction::Continue => {}
|
||||
comms::CommsAction::Refresh => self.refresh_comms(),
|
||||
comms::CommsAction::SendMessage { from, to, msg } => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_comms_send(backend, from, to, msg, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
comms::CommsAction::PostTask {
|
||||
title,
|
||||
desc,
|
||||
assign,
|
||||
} => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_comms_task(backend, title, desc, assign, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_logs_action(&mut self, action: logs::LogsAction) {
|
||||
match action {
|
||||
logs::LogsAction::Continue => {}
|
||||
@@ -2029,6 +2089,7 @@ impl App {
|
||||
Tab::Usage => usage::draw(frame, chunks[1], &mut self.usage),
|
||||
Tab::Settings => settings::draw(frame, chunks[1], &mut self.settings),
|
||||
Tab::Peers => peers::draw(frame, chunks[1], &mut self.peers),
|
||||
Tab::Comms => comms::draw(frame, chunks[1], &mut self.comms),
|
||||
Tab::Logs => logs::draw(frame, chunks[1], &mut self.logs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
//! Comms screen: Agent communication topology + live event feed.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub kind: String, // "parent_child" or "peer"
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsEventItem {
|
||||
/// Event ID — used by the dashboard for dedup, kept for wire compat.
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
pub kind: String,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommsFocus {
|
||||
Topology,
|
||||
EventList,
|
||||
}
|
||||
|
||||
pub struct CommsState {
|
||||
pub nodes: Vec<CommsNode>,
|
||||
pub edges: Vec<CommsEdge>,
|
||||
pub events: Vec<CommsEventItem>,
|
||||
pub event_list_state: ListState,
|
||||
pub focus: CommsFocus,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub poll_tick: usize,
|
||||
// Send modal
|
||||
pub show_send_modal: bool,
|
||||
pub send_from: String,
|
||||
pub send_to: String,
|
||||
pub send_msg: String,
|
||||
pub send_field: usize,
|
||||
// Task modal
|
||||
pub show_task_modal: bool,
|
||||
pub task_title: String,
|
||||
pub task_desc: String,
|
||||
pub task_assign: String,
|
||||
pub task_field: usize,
|
||||
// Status
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum CommsAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
SendMessage {
|
||||
from: String,
|
||||
to: String,
|
||||
msg: String,
|
||||
},
|
||||
PostTask {
|
||||
title: String,
|
||||
desc: String,
|
||||
assign: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CommsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
events: Vec::new(),
|
||||
event_list_state: ListState::default(),
|
||||
focus: CommsFocus::Topology,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
poll_tick: 0,
|
||||
show_send_modal: false,
|
||||
send_from: String::new(),
|
||||
send_to: String::new(),
|
||||
send_msg: String::new(),
|
||||
send_field: 0,
|
||||
show_task_modal: false,
|
||||
task_title: String::new(),
|
||||
task_desc: String::new(),
|
||||
task_assign: String::new(),
|
||||
task_field: 0,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.poll_tick = self.poll_tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Auto-refresh every ~5s at 20fps tick rate.
|
||||
pub fn should_poll(&self) -> bool {
|
||||
self.poll_tick > 0 && self.poll_tick.is_multiple_of(100)
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return CommsAction::Continue;
|
||||
}
|
||||
|
||||
// Modal key handling
|
||||
if self.show_send_modal {
|
||||
return self.handle_send_modal_key(key);
|
||||
}
|
||||
if self.show_task_modal {
|
||||
return self.handle_task_modal_key(key);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Tab => {
|
||||
self.focus = match self.focus {
|
||||
CommsFocus::Topology => CommsFocus::EventList,
|
||||
CommsFocus::EventList => CommsFocus::Topology,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
self.show_send_modal = true;
|
||||
self.send_from.clear();
|
||||
self.send_to.clear();
|
||||
self.send_msg.clear();
|
||||
self.send_field = 0;
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
self.show_task_modal = true;
|
||||
self.task_title.clear();
|
||||
self.task_desc.clear();
|
||||
self.task_assign.clear();
|
||||
self.task_field = 0;
|
||||
}
|
||||
KeyCode::Char('r') => return CommsAction::Refresh,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.events.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_send_modal_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_send_modal = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.send_field = (self.send_field + 1) % 3;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.send_field = if self.send_field == 0 {
|
||||
2
|
||||
} else {
|
||||
self.send_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.send_from.is_empty()
|
||||
&& !self.send_to.is_empty()
|
||||
&& !self.send_msg.is_empty()
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.send_field {
|
||||
0 => self.send_from.push(c),
|
||||
1 => self.send_to.push(c),
|
||||
_ => self.send_msg.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match self.send_field {
|
||||
0 => {
|
||||
self.send_from.pop();
|
||||
}
|
||||
1 => {
|
||||
self.send_to.pop();
|
||||
}
|
||||
_ => {
|
||||
self.send_msg.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_task_modal_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_task_modal = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.task_field = (self.task_field + 1) % 3;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.task_field = if self.task_field == 0 {
|
||||
2
|
||||
} else {
|
||||
self.task_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.task_title.is_empty() {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.task_field {
|
||||
0 => self.task_title.push(c),
|
||||
1 => self.task_desc.push(c),
|
||||
_ => self.task_assign.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match self.task_field {
|
||||
0 => {
|
||||
self.task_title.pop();
|
||||
}
|
||||
1 => {
|
||||
self.task_desc.pop();
|
||||
}
|
||||
_ => {
|
||||
self.task_assign.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
// ── Topology helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn root_nodes(&self) -> Vec<&CommsNode> {
|
||||
let child_ids: std::collections::HashSet<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "parent_child")
|
||||
.map(|e| e.to.as_str())
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| !child_ids.contains(n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn children_of(&self, id: &str) -> Vec<&CommsNode> {
|
||||
let child_ids: Vec<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "parent_child" && e.from == id)
|
||||
.map(|e| e.to.as_str())
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| child_ids.contains(&n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn peers_of(&self, id: &str) -> Vec<&CommsNode> {
|
||||
let peer_ids: std::collections::HashSet<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "peer")
|
||||
.filter_map(|e| {
|
||||
if e.from == id {
|
||||
Some(e.to.as_str())
|
||||
} else if e.to == id {
|
||||
Some(e.from.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| peer_ids.contains(n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Comms ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Percentage(35), // topology
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // event list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Header
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" Agent Topology ({} agents, {} edges)",
|
||||
state.nodes.len(),
|
||||
state.edges.len()
|
||||
),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(""),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Separator
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"\u{2500}".repeat(inner.width as usize),
|
||||
theme::dim_style(),
|
||||
))),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
// Topology tree
|
||||
draw_topology(f, chunks[2], state);
|
||||
|
||||
// Separator
|
||||
let event_label = if state.focus == CommsFocus::EventList {
|
||||
" \u{25b6} Live Event Feed"
|
||||
} else {
|
||||
" Live Event Feed"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
event_label,
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({} events)", state.events.len()),
|
||||
theme::dim_style(),
|
||||
),
|
||||
])),
|
||||
chunks[3],
|
||||
);
|
||||
|
||||
// Event list
|
||||
draw_event_list(f, chunks[4], state);
|
||||
|
||||
// Status message or hints
|
||||
let hint_text = if !state.status_msg.is_empty() {
|
||||
format!(
|
||||
" {} | [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll",
|
||||
state.status_msg
|
||||
)
|
||||
} else {
|
||||
" [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll".to_string()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(hint_text, theme::hint_style()))),
|
||||
chunks[5],
|
||||
);
|
||||
|
||||
// Modal overlays
|
||||
if state.show_send_modal {
|
||||
draw_send_modal(f, area, state);
|
||||
}
|
||||
if state.show_task_modal {
|
||||
draw_task_modal(f, area, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
if state.loading && state.nodes.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading topology\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if state.nodes.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No agents running.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let focus_highlight = state.focus == CommsFocus::Topology;
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for root in state.root_nodes() {
|
||||
let state_style = state_color(&root.state);
|
||||
let mut spans = vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(format!("[{}]", &root.state), state_style),
|
||||
Span::styled(
|
||||
format!(" {} ", root.name),
|
||||
Style::default()
|
||||
.fg(if focus_highlight {
|
||||
theme::CYAN
|
||||
} else {
|
||||
theme::TEXT
|
||||
})
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("({})", root.model), theme::dim_style()),
|
||||
];
|
||||
// Peer annotations
|
||||
for peer in state.peers_of(&root.id) {
|
||||
spans.push(Span::styled(
|
||||
format!(" \u{2194} {}", peer.name),
|
||||
Style::default().fg(theme::PURPLE),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
|
||||
// Children
|
||||
let children = state.children_of(&root.id);
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
let branch = if i < children.len() - 1 {
|
||||
"\u{251c}\u{2500}\u{2500} "
|
||||
} else {
|
||||
"\u{2514}\u{2500}\u{2500} "
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(branch, theme::dim_style()),
|
||||
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
|
||||
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
|
||||
Span::styled(format!("({})", child.model), theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn draw_event_list(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
if state.events.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No inter-agent events yet.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = state
|
||||
.events
|
||||
.iter()
|
||||
.map(|ev| {
|
||||
let kind_style = kind_color(&ev.kind);
|
||||
let kind_label = kind_short(&ev.kind);
|
||||
let target_part = if ev.target_name.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" \u{2192} {}", ev.target_name)
|
||||
};
|
||||
let detail = truncate(&ev.detail, 50);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<8}", short_time(&ev.timestamp)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(format!(" {:<10}", kind_label), kind_style),
|
||||
Span::styled(
|
||||
format!(" {}", ev.source_name),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(target_part, Style::default().fg(theme::PURPLE)),
|
||||
Span::styled(format!(" {detail}"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, area, &mut state.event_list_state);
|
||||
}
|
||||
|
||||
fn draw_send_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
let modal = centered_rect(50, 12, area);
|
||||
f.render_widget(Clear, modal);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Send Message ", theme::title_style()))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(modal);
|
||||
f.render_widget(block, modal);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let field_style = |idx: usize| {
|
||||
if state.send_field == idx {
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
}
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("From (agent ID):", field_style(0))),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_from),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("To (agent ID):", field_style(1))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_to),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Message:", field_style(2))),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_msg),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[5],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"[Tab] field [Enter] send [Esc] cancel",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
rows[6],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
let modal = centered_rect(50, 12, area);
|
||||
f.render_widget(Clear, modal);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Post Task ", theme::title_style()))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(modal);
|
||||
f.render_widget(block, modal);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let field_style = |idx: usize| {
|
||||
if state.task_field == idx {
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
}
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Title:", field_style(0))),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_title),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Description:", field_style(1))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_desc),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_assign),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[5],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"[Tab] field [Enter] post [Esc] cancel",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
rows[6],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn state_color(state: &str) -> Style {
|
||||
match state {
|
||||
"Running" => Style::default().fg(theme::GREEN),
|
||||
"Suspended" => Style::default().fg(theme::YELLOW),
|
||||
"Terminated" | "Crashed" => Style::default().fg(theme::RED),
|
||||
_ => theme::dim_style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_color(kind: &str) -> Style {
|
||||
match kind {
|
||||
"agent_message" => Style::default().fg(theme::CYAN),
|
||||
"agent_spawned" => Style::default().fg(theme::GREEN),
|
||||
"agent_terminated" => Style::default().fg(theme::RED),
|
||||
"task_posted" => Style::default().fg(theme::YELLOW),
|
||||
"task_claimed" => Style::default().fg(theme::CYAN),
|
||||
"task_completed" => Style::default().fg(theme::GREEN),
|
||||
_ => theme::dim_style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_short(kind: &str) -> &str {
|
||||
match kind {
|
||||
"agent_message" => "MSG",
|
||||
"agent_spawned" => "SPAWNED",
|
||||
"agent_terminated" => "KILLED",
|
||||
"task_posted" => "TASK+",
|
||||
"task_claimed" => "CLAIM",
|
||||
"task_completed" => "DONE",
|
||||
_ => kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn short_time(ts: &str) -> String {
|
||||
// Extract HH:MM:SS from ISO-8601
|
||||
if let Some(t_pos) = ts.find('T') {
|
||||
let time_part = &ts[t_pos + 1..];
|
||||
if time_part.len() >= 8 {
|
||||
return time_part[..8].to_string();
|
||||
}
|
||||
}
|
||||
ts.chars().take(8).collect()
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
|
||||
let w = area.width * percent_x / 100;
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
Rect::new(x, y, w, height.min(area.height))
|
||||
}
|
||||
@@ -408,7 +408,7 @@ impl State {
|
||||
|
||||
for m in &models {
|
||||
match m.tier {
|
||||
ModelTier::Fast | ModelTier::Local => {
|
||||
ModelTier::Fast | ModelTier::Local | ModelTier::Custom => {
|
||||
if fast.is_none() {
|
||||
fast = Some(&m.id);
|
||||
}
|
||||
@@ -461,6 +461,7 @@ fn tier_label(tier: ModelTier) -> &'static str {
|
||||
ModelTier::Balanced => "balanced",
|
||||
ModelTier::Fast => "fast",
|
||||
ModelTier::Local => "local",
|
||||
ModelTier::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod channels;
|
||||
pub mod comms;
|
||||
pub mod chat;
|
||||
pub mod dashboard;
|
||||
pub mod extensions;
|
||||
|
||||
@@ -34,6 +34,7 @@ pub const PURPLE: Color = Color::Rgb(168, 85, 247); // #A855F7 — decorators
|
||||
|
||||
pub const CYAN: Color = BLUE;
|
||||
pub const DIM: Color = TEXT_SECONDARY;
|
||||
pub const TEXT: Color = TEXT_PRIMARY;
|
||||
|
||||
// ── Reusable styles ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -230,6 +230,21 @@ impl HandRegistry {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Update config for an active hand instance.
|
||||
pub fn update_config(
|
||||
&self,
|
||||
instance_id: Uuid,
|
||||
config: HashMap<String, serde_json::Value>,
|
||||
) -> HandResult<()> {
|
||||
let mut entry = self
|
||||
.instances
|
||||
.get_mut(&instance_id)
|
||||
.ok_or(HandError::InstanceNotFound(instance_id))?;
|
||||
entry.config = config;
|
||||
entry.updated_at = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an instance as errored.
|
||||
pub fn set_error(&self, instance_id: Uuid, message: String) -> HandResult<()> {
|
||||
let mut entry = self
|
||||
|
||||
@@ -181,6 +181,7 @@ mod tests {
|
||||
require_approval: vec!["file_write".to_string(), "file_delete".to_string()],
|
||||
timeout_secs: 30,
|
||||
auto_approve_autonomous: false,
|
||||
auto_approve: false,
|
||||
};
|
||||
let mgr = ApprovalManager::new(policy);
|
||||
assert!(mgr.requires_approval("file_write"));
|
||||
@@ -258,6 +259,7 @@ mod tests {
|
||||
require_approval: vec!["file_write".to_string()],
|
||||
timeout_secs: 120,
|
||||
auto_approve_autonomous: true,
|
||||
auto_approve: false,
|
||||
};
|
||||
mgr.update_policy(new_policy);
|
||||
|
||||
|
||||
@@ -234,9 +234,9 @@ impl CronScheduler {
|
||||
if meta.job.enabled && meta.job.next_run.map(|t| t <= now).unwrap_or(false) {
|
||||
due.push(meta.job.clone());
|
||||
// Pre-advance next_run so the job won't fire again on the next
|
||||
// tick while it's still executing. record_success/record_failure
|
||||
// will recompute it again after execution completes.
|
||||
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
|
||||
// tick while it's still executing. Use `now` as the base so the
|
||||
// next fire time is computed strictly after the current moment.
|
||||
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, now));
|
||||
}
|
||||
}
|
||||
due
|
||||
@@ -255,12 +255,9 @@ impl CronScheduler {
|
||||
meta.job.last_run = Some(Utc::now());
|
||||
meta.last_status = Some("ok".to_string());
|
||||
meta.consecutive_errors = 0;
|
||||
if meta.one_shot {
|
||||
true
|
||||
} else {
|
||||
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
|
||||
false
|
||||
}
|
||||
// one_shot jobs get removed; recurring jobs keep the next_run
|
||||
// already pre-advanced by due_jobs() — no recompute needed.
|
||||
meta.one_shot
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -277,7 +274,10 @@ impl CronScheduler {
|
||||
pub fn record_failure(&self, id: CronJobId, error_msg: &str) {
|
||||
if let Some(mut meta) = self.jobs.get_mut(&id) {
|
||||
meta.job.last_run = Some(Utc::now());
|
||||
meta.last_status = Some(format!("error: {}", &error_msg[..error_msg.len().min(256)]));
|
||||
meta.last_status = Some(format!(
|
||||
"error: {}",
|
||||
openfang_types::truncate_str(error_msg, 256)
|
||||
));
|
||||
meta.consecutive_errors += 1;
|
||||
if meta.consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
|
||||
warn!(
|
||||
@@ -287,7 +287,8 @@ impl CronScheduler {
|
||||
);
|
||||
meta.job.enabled = false;
|
||||
} else {
|
||||
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
|
||||
meta.job.next_run =
|
||||
Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,7 +298,7 @@ impl CronScheduler {
|
||||
// compute_next_run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the next fire time for a schedule.
|
||||
/// Compute the next fire time for a schedule, based on `now`.
|
||||
///
|
||||
/// - `At { at }` — returns `at` directly.
|
||||
/// - `Every { every_secs }` — returns `now + every_secs`.
|
||||
@@ -306,9 +307,23 @@ impl CronScheduler {
|
||||
/// 6-field (`sec min hour dom month dow`) formats by converting to the
|
||||
/// 7-field format required by the `cron` crate.
|
||||
pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
|
||||
compute_next_run_after(schedule, Utc::now())
|
||||
}
|
||||
|
||||
/// Compute the next fire time for a schedule, strictly after `after`.
|
||||
///
|
||||
/// Uses `after + 1 second` as the base time so the `cron` crate's
|
||||
/// inclusive `.after()` always returns a strictly future time. Without
|
||||
/// this offset, calling `compute_next_run` right after a job fires can
|
||||
/// return the same minute (or even the same second), causing the
|
||||
/// scheduler to re-fire immediately.
|
||||
pub fn compute_next_run_after(
|
||||
schedule: &CronSchedule,
|
||||
after: chrono::DateTime<Utc>,
|
||||
) -> chrono::DateTime<Utc> {
|
||||
match schedule {
|
||||
CronSchedule::At { at } => *at,
|
||||
CronSchedule::Every { every_secs } => Utc::now() + Duration::seconds(*every_secs as i64),
|
||||
CronSchedule::Every { every_secs } => after + Duration::seconds(*every_secs as i64),
|
||||
CronSchedule::Cron { expr, tz: _ } => {
|
||||
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
|
||||
// Standard 5-field: min hour dom month dow
|
||||
@@ -322,14 +337,17 @@ pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
|
||||
_ => expr.clone(),
|
||||
};
|
||||
|
||||
// Add 1 second so `.after()` (inclusive) skips the current second.
|
||||
let base = after + Duration::seconds(1);
|
||||
|
||||
match seven_field.parse::<cron::Schedule>() {
|
||||
Ok(sched) => sched
|
||||
.upcoming(Utc)
|
||||
.after(&base)
|
||||
.next()
|
||||
.unwrap_or_else(|| Utc::now() + Duration::hours(1)),
|
||||
.unwrap_or_else(|| after + Duration::hours(1)),
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cron expression '{}': {}", expr, e);
|
||||
Utc::now() + Duration::hours(1)
|
||||
after + Duration::hours(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -729,6 +747,27 @@ mod tests {
|
||||
|
||||
// -- error message truncation in record_failure -------------------------
|
||||
|
||||
#[test]
|
||||
fn test_compute_next_run_after_skips_current_second() {
|
||||
// A "every 4 hours" cron: next_run should be >= 4 hours from now,
|
||||
// not in the same minute (the bug from #55).
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 */4 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next = compute_next_run_after(&schedule, now);
|
||||
// Must be strictly after `now` and at least ~1 hour away
|
||||
// (the closest 4-hourly boundary is at least minutes away).
|
||||
assert!(next > now, "next_run should be strictly after now");
|
||||
let diff = next - now;
|
||||
assert!(
|
||||
diff.num_minutes() >= 1,
|
||||
"Expected next_run at least 1 min away, got {} seconds",
|
||||
diff.num_seconds()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_failure_truncates_long_error() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
|
||||
@@ -14,7 +14,9 @@ use crate::triggers::{TriggerEngine, TriggerId, TriggerPattern};
|
||||
use crate::workflow::{StepAgent, Workflow, WorkflowEngine, WorkflowId, WorkflowRunId};
|
||||
|
||||
use openfang_memory::MemorySubstrate;
|
||||
use openfang_runtime::agent_loop::{run_agent_loop, run_agent_loop_streaming, AgentLoopResult};
|
||||
use openfang_runtime::agent_loop::{
|
||||
run_agent_loop, run_agent_loop_streaming, strip_provider_prefix, AgentLoopResult,
|
||||
};
|
||||
use openfang_runtime::audit::AuditLog;
|
||||
use openfang_runtime::drivers;
|
||||
use openfang_runtime::kernel_handle::{self, KernelHandle};
|
||||
@@ -446,7 +448,7 @@ fn read_identity_file(workspace: &Path, filename: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
if content.len() > MAX_IDENTITY_FILE_BYTES {
|
||||
Some(content[..MAX_IDENTITY_FILE_BYTES].to_string())
|
||||
Some(openfang_types::truncate_str(&content, MAX_IDENTITY_FILE_BYTES).to_string())
|
||||
} else {
|
||||
Some(content)
|
||||
}
|
||||
@@ -483,6 +485,11 @@ impl OpenFangKernel {
|
||||
pub fn boot_with_config(mut config: KernelConfig) -> KernelResult<Self> {
|
||||
use openfang_types::config::KernelMode;
|
||||
|
||||
// Env var overrides — useful for Docker where config.toml is baked in.
|
||||
if let Ok(listen) = std::env::var("OPENFANG_LISTEN") {
|
||||
config.api_listen = listen;
|
||||
}
|
||||
|
||||
// Clamp configuration bounds to prevent zero-value or unbounded misconfigs
|
||||
config.clamp_bounds();
|
||||
|
||||
@@ -598,6 +605,9 @@ impl OpenFangKernel {
|
||||
config.provider_urls.len()
|
||||
);
|
||||
}
|
||||
// Load user's custom models from ~/.openfang/custom_models.json
|
||||
let custom_models_path = config.home_dir.join("custom_models.json");
|
||||
model_catalog.load_custom_models(&custom_models_path);
|
||||
let available_count = model_catalog.available_models().len();
|
||||
let total_count = model_catalog.list_models().len();
|
||||
let local_count = model_catalog
|
||||
@@ -1890,7 +1900,7 @@ impl OpenFangKernel {
|
||||
router.resolve_aliases(&self.model_catalog.read().unwrap_or_else(|e| e.into_inner()));
|
||||
// Build a probe request to score complexity
|
||||
let probe = CompletionRequest {
|
||||
model: manifest.model.model.clone(),
|
||||
model: strip_provider_prefix(&manifest.model.model, &manifest.model.provider),
|
||||
messages: vec![openfang_types::message::Message::user(message)],
|
||||
tools: tools.clone(),
|
||||
max_tokens: manifest.model.max_tokens,
|
||||
@@ -2252,7 +2262,10 @@ impl OpenFangKernel {
|
||||
.map(|entry| entry.provider.clone())
|
||||
});
|
||||
|
||||
if let Some(provider) = resolved_provider {
|
||||
// If catalog lookup failed, try to infer provider from model name prefix
|
||||
let provider = resolved_provider.or_else(|| infer_provider_from_model(model));
|
||||
|
||||
if let Some(provider) = provider {
|
||||
self.registry
|
||||
.update_model_and_provider(agent_id, model.to_string(), provider.clone())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
@@ -2261,7 +2274,7 @@ impl OpenFangKernel {
|
||||
self.registry
|
||||
.update_model(agent_id, model.to_string())
|
||||
.map_err(KernelError::OpenFang)?;
|
||||
info!(agent_id = %agent_id, model = %model, "Agent model updated");
|
||||
info!(agent_id = %agent_id, model = %model, "Agent model updated (provider unchanged)");
|
||||
}
|
||||
|
||||
// Persist the updated entry
|
||||
@@ -3491,43 +3504,50 @@ impl OpenFangKernel {
|
||||
let primary = if agent_provider == default_provider && !has_custom_key && !has_custom_url {
|
||||
Arc::clone(&self.default_driver)
|
||||
} else {
|
||||
// Create a dedicated driver for this agent
|
||||
// Auth profile rotation: if profiles are configured for this provider,
|
||||
// select the highest-priority profile's key env var.
|
||||
let default_key_env = manifest
|
||||
.model
|
||||
.api_key_env
|
||||
.as_deref()
|
||||
.unwrap_or(&self.config.default_model.api_key_env);
|
||||
|
||||
let api_key_env =
|
||||
// Create a dedicated driver for this agent.
|
||||
//
|
||||
// IMPORTANT: When the agent's provider differs from the default,
|
||||
// we must NOT pass the default provider's API key. Instead, pass None
|
||||
// so create_driver() can look up the correct env var for the target provider.
|
||||
let api_key = if has_custom_key {
|
||||
// Agent explicitly set an API key env var — use it
|
||||
manifest
|
||||
.model
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
} else if agent_provider == default_provider {
|
||||
// Same provider — use default key
|
||||
std::env::var(&self.config.default_model.api_key_env).ok()
|
||||
} else {
|
||||
// Different provider — check auth profiles first, then let
|
||||
// create_driver() look up the correct env var automatically.
|
||||
if let Some(profiles) = self.config.auth_profiles.get(agent_provider.as_str()) {
|
||||
if !profiles.is_empty() {
|
||||
// Pick highest-priority profile (lowest priority number)
|
||||
let mut sorted: Vec<_> = profiles.iter().collect();
|
||||
sorted.sort_by_key(|p| p.priority);
|
||||
let best = &sorted[0];
|
||||
// Use the profile's env var if the key exists, otherwise fall back
|
||||
if std::env::var(&best.api_key_env).is_ok() {
|
||||
best.api_key_env.clone()
|
||||
} else {
|
||||
default_key_env.to_string()
|
||||
}
|
||||
} else {
|
||||
default_key_env.to_string()
|
||||
}
|
||||
let mut sorted: Vec<_> = profiles.iter().collect();
|
||||
sorted.sort_by_key(|p| p.priority);
|
||||
sorted
|
||||
.first()
|
||||
.and_then(|best| std::env::var(&best.api_key_env).ok())
|
||||
} else {
|
||||
default_key_env.to_string()
|
||||
};
|
||||
// Pass None — create_driver() has per-provider env var lookups
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Don't inherit default provider's base_url when switching providers
|
||||
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()
|
||||
} else {
|
||||
// Let create_driver() use the target provider's default base URL
|
||||
None
|
||||
};
|
||||
|
||||
let driver_config = DriverConfig {
|
||||
provider: agent_provider.clone(),
|
||||
api_key: std::env::var(&api_key_env).ok(),
|
||||
base_url: manifest
|
||||
.model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.default_model.base_url.clone()),
|
||||
api_key,
|
||||
base_url,
|
||||
};
|
||||
|
||||
drivers::create_driver(&driver_config).map_err(|e| {
|
||||
@@ -4231,6 +4251,62 @@ fn manifest_to_capabilities(manifest: &AgentManifest) -> Vec<Capability> {
|
||||
caps
|
||||
}
|
||||
|
||||
/// Infer provider from a model name when catalog lookup fails.
|
||||
///
|
||||
/// Uses well-known model name prefixes to map to the correct provider.
|
||||
/// This is a defense-in-depth fallback — models should ideally be in the catalog.
|
||||
fn infer_provider_from_model(model: &str) -> Option<String> {
|
||||
let lower = model.to_lowercase();
|
||||
// Check for explicit provider prefix (e.g., "minimax/MiniMax-M2.5")
|
||||
if let Some(prefix) = lower.split('/').next() {
|
||||
match prefix {
|
||||
"minimax" | "gemini" | "anthropic" | "openai" | "groq" | "deepseek" | "mistral"
|
||||
| "cohere" | "xai" | "ollama" | "together" | "fireworks" | "perplexity"
|
||||
| "cerebras" | "sambanova" | "replicate" | "huggingface" | "ai21" | "codex"
|
||||
| "claude-code" | "copilot" | "github-copilot" | "qwen" | "zhipu" | "moonshot"
|
||||
| "openrouter" => {
|
||||
if model.contains('/') {
|
||||
return Some(prefix.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Infer from well-known model name patterns
|
||||
if lower.starts_with("minimax") {
|
||||
Some("minimax".to_string())
|
||||
} else if lower.starts_with("gemini") {
|
||||
Some("gemini".to_string())
|
||||
} else if lower.starts_with("claude") {
|
||||
Some("anthropic".to_string())
|
||||
} else if lower.starts_with("gpt") || lower.starts_with("o1") || lower.starts_with("o3") || lower.starts_with("o4") {
|
||||
Some("openai".to_string())
|
||||
} else if lower.starts_with("llama") || lower.starts_with("mixtral") || lower.starts_with("qwen") {
|
||||
// These could be on multiple providers; don't infer
|
||||
None
|
||||
} else if lower.starts_with("grok") {
|
||||
Some("xai".to_string())
|
||||
} else if lower.starts_with("deepseek") {
|
||||
Some("deepseek".to_string())
|
||||
} else if lower.starts_with("mistral") || lower.starts_with("codestral") || lower.starts_with("pixtral") {
|
||||
Some("mistral".to_string())
|
||||
} else if lower.starts_with("command") || lower.starts_with("embed-") {
|
||||
Some("cohere".to_string())
|
||||
} else if lower.starts_with("jamba") {
|
||||
Some("ai21".to_string())
|
||||
} else if lower.starts_with("sonar") {
|
||||
Some("perplexity".to_string())
|
||||
} else if lower.starts_with("glm") {
|
||||
Some("zhipu".to_string())
|
||||
} else if lower.starts_with("ernie") {
|
||||
Some("qianfan".to_string())
|
||||
} else if lower.starts_with("abab") {
|
||||
Some("minimax".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-known agent ID used for shared memory operations across agents.
|
||||
/// This is a fixed UUID so all agents read/write to the same namespace.
|
||||
fn shared_memory_agent_id() -> AgentId {
|
||||
|
||||
@@ -556,6 +556,7 @@ impl SessionStore {
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name: _,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
|
||||
@@ -51,6 +51,20 @@ const MAX_CONTINUATIONS: u32 = 5;
|
||||
/// Maximum message history size before auto-trimming to prevent context overflow.
|
||||
const MAX_HISTORY_MESSAGES: usize = 20;
|
||||
|
||||
/// Strip a provider prefix from a model ID before sending to the API.
|
||||
///
|
||||
/// Many models are stored as `provider/org/model` (e.g. `openrouter/google/gemini-2.5-flash`)
|
||||
/// but the upstream API expects just `org/model`. This also handles special routers
|
||||
/// like `openrouter/auto` → `auto`.
|
||||
pub fn strip_provider_prefix(model: &str, provider: &str) -> String {
|
||||
let prefix = format!("{}/", provider);
|
||||
if model.starts_with(&prefix) {
|
||||
model[prefix.len()..].to_string()
|
||||
} else {
|
||||
model.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Default context window size (tokens) for token-based trimming.
|
||||
const DEFAULT_CONTEXT_WINDOW: usize = 200_000;
|
||||
|
||||
@@ -281,8 +295,11 @@ pub async fn run_agent_loop(
|
||||
// Context guard: compact oversized tool results before LLM call
|
||||
apply_context_guard(&mut messages, &context_budget, available_tools);
|
||||
|
||||
// Strip provider prefix: "openrouter/google/gemini-2.5-flash" → "google/gemini-2.5-flash"
|
||||
let api_model = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: manifest.model.model.clone(),
|
||||
model: api_model,
|
||||
messages: messages.clone(),
|
||||
tools: available_tools.to_vec(),
|
||||
max_tokens: manifest.model.max_tokens,
|
||||
@@ -536,6 +553,7 @@ pub async fn run_agent_loop(
|
||||
warn!(tool = %tool_call.name, "Tool call blocked by loop guard");
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: msg.clone(),
|
||||
is_error: true,
|
||||
});
|
||||
@@ -573,6 +591,7 @@ pub async fn run_agent_loop(
|
||||
if let Err(reason) = hook_reg.fire(&ctx) {
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: format!(
|
||||
"Hook blocked tool '{}': {}",
|
||||
tool_call.name, reason
|
||||
@@ -656,6 +675,7 @@ pub async fn run_agent_loop(
|
||||
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: result.tool_use_id,
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: final_content,
|
||||
is_error: result.is_error,
|
||||
});
|
||||
@@ -1186,8 +1206,11 @@ pub async fn run_agent_loop_streaming(
|
||||
// Context guard: compact oversized tool results before LLM call
|
||||
apply_context_guard(&mut messages, &context_budget, available_tools);
|
||||
|
||||
// Strip provider prefix: "openrouter/google/gemini-2.5-flash" → "google/gemini-2.5-flash"
|
||||
let api_model = strip_provider_prefix(&manifest.model.model, &manifest.model.provider);
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: manifest.model.model.clone(),
|
||||
model: api_model,
|
||||
messages: messages.clone(),
|
||||
tools: available_tools.to_vec(),
|
||||
max_tokens: manifest.model.max_tokens,
|
||||
@@ -1439,6 +1462,7 @@ pub async fn run_agent_loop_streaming(
|
||||
warn!(tool = %tool_call.name, "Tool call blocked by loop guard (streaming)");
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: msg.clone(),
|
||||
is_error: true,
|
||||
});
|
||||
@@ -1476,6 +1500,7 @@ pub async fn run_agent_loop_streaming(
|
||||
if let Err(reason) = hook_reg.fire(&ctx) {
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call.id.clone(),
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: format!(
|
||||
"Hook blocked tool '{}': {}",
|
||||
tool_call.name, reason
|
||||
@@ -1573,6 +1598,7 @@ pub async fn run_agent_loop_streaming(
|
||||
|
||||
tool_result_blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: result.tool_use_id,
|
||||
tool_name: tool_call.name.clone(),
|
||||
content: final_content,
|
||||
is_error: result.is_error,
|
||||
});
|
||||
|
||||
@@ -426,8 +426,14 @@ async fn summarize_messages(
|
||||
let effective_max = (config.max_chunk_chars as f64 / config.safety_margin) as usize;
|
||||
if conversation_text.len() > effective_max {
|
||||
// Keep the tail (most recent) which is usually more important
|
||||
conversation_text =
|
||||
conversation_text[conversation_text.len() - effective_max..].to_string();
|
||||
let start = conversation_text.len() - effective_max;
|
||||
// Find valid char boundary at or after start
|
||||
let safe_start = if conversation_text.is_char_boundary(start) {
|
||||
start
|
||||
} else {
|
||||
conversation_text[start..].char_indices().next().map(|(i, _)| start + i).unwrap_or(conversation_text.len())
|
||||
};
|
||||
conversation_text = conversation_text[safe_start..].to_string();
|
||||
}
|
||||
|
||||
let summarize_prompt = format!(
|
||||
@@ -850,6 +856,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Search results here".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1184,6 +1191,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Results found".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1324,6 +1332,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: tool_content,
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1343,6 +1352,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t2".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: large_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1366,6 +1376,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t3".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: short_result.to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
|
||||
@@ -64,12 +64,23 @@ pub fn truncate_tool_result_dynamic(content: &str, budget: &ContextBudget) -> St
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
// Find last newline before the cap to break cleanly
|
||||
let search_start = cap.saturating_sub(200);
|
||||
let break_point = content[search_start..cap]
|
||||
// Find last newline before the cap to break cleanly (char-boundary safe)
|
||||
let safe_cap = if content.is_char_boundary(cap) {
|
||||
cap
|
||||
} else {
|
||||
content[..cap].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
let search_start = safe_cap.saturating_sub(200);
|
||||
let break_point = content[search_start..safe_cap]
|
||||
.rfind('\n')
|
||||
.map(|pos| search_start + pos)
|
||||
.unwrap_or(cap.saturating_sub(100));
|
||||
.unwrap_or(safe_cap.saturating_sub(100));
|
||||
// Ensure break_point is also a char boundary
|
||||
let break_point = if content.is_char_boundary(break_point) {
|
||||
break_point
|
||||
} else {
|
||||
content[..break_point].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}\n\n[TRUNCATED: result was {} chars, showing first {} (budget: {}% of {}K context window)]",
|
||||
@@ -248,6 +259,7 @@ mod tests {
|
||||
role: openfang_types::message::Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_result.clone(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -256,6 +268,7 @@ mod tests {
|
||||
role: openfang_types::message::Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t2".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
|
||||
@@ -103,11 +103,17 @@ pub fn recover_from_overflow(
|
||||
if let ContentBlock::ToolResult { content, .. } = block {
|
||||
if content.len() > tool_truncation_limit {
|
||||
let keep = tool_truncation_limit.saturating_sub(80);
|
||||
// Find a valid char boundary at or before `keep`
|
||||
let safe_keep = if content.is_char_boundary(keep) {
|
||||
keep
|
||||
} else {
|
||||
content[..keep].char_indices().next_back().map(|(i, _)| i).unwrap_or(0)
|
||||
};
|
||||
*content = format!(
|
||||
"{}\n\n[OVERFLOW RECOVERY: truncated from {} to {} chars]",
|
||||
&content[..keep],
|
||||
&content[..safe_keep],
|
||||
content.len(),
|
||||
keep
|
||||
safe_keep
|
||||
);
|
||||
truncated += 1;
|
||||
}
|
||||
@@ -202,6 +208,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_result.clone(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -210,6 +217,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "t2".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: big_result,
|
||||
is_error: false,
|
||||
}]),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! GitHub Copilot OAuth — device flow for obtaining a GitHub PAT via browser login.
|
||||
//!
|
||||
//! Implements the OAuth 2.0 Device Authorization Grant (RFC 8628) using GitHub's
|
||||
//! device flow endpoint. Users visit a URL, enter a code, and authorize the app.
|
||||
//! Once complete, the resulting access token can be used with the CopilotDriver.
|
||||
|
||||
use serde::Deserialize;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// GitHub device code request URL.
|
||||
const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
|
||||
|
||||
/// GitHub OAuth token URL.
|
||||
const GITHUB_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
|
||||
|
||||
/// Public OAuth client ID — same as VSCode Copilot extension.
|
||||
const COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
|
||||
|
||||
/// Response from the device code initiation request.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
/// Status of a device flow polling attempt.
|
||||
pub enum DeviceFlowStatus {
|
||||
/// Authorization is pending — user hasn't completed the flow yet.
|
||||
Pending,
|
||||
/// Authorization succeeded — contains the access token.
|
||||
Complete { access_token: Zeroizing<String> },
|
||||
/// Server asked to slow down — use the new interval.
|
||||
SlowDown { new_interval: u64 },
|
||||
/// The device code expired — user must restart the flow.
|
||||
Expired,
|
||||
/// User explicitly denied access.
|
||||
AccessDenied,
|
||||
/// An unexpected error occurred.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Start a GitHub device flow for Copilot OAuth.
|
||||
///
|
||||
/// POST https://github.com/login/device/code
|
||||
/// Returns a device code and user code for the user to enter at the verification URI.
|
||||
pub async fn start_device_flow() -> Result<DeviceCodeResponse, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client error: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.post(GITHUB_DEVICE_CODE_URL)
|
||||
.header("Accept", "application/json")
|
||||
.form(&[("client_id", COPILOT_CLIENT_ID), ("scope", "read:user")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device code request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Device code request returned {status}: {body}"));
|
||||
}
|
||||
|
||||
resp.json::<DeviceCodeResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device code response: {e}"))
|
||||
}
|
||||
|
||||
/// Poll the GitHub token endpoint for the device flow result.
|
||||
///
|
||||
/// POST https://github.com/login/oauth/access_token
|
||||
/// Returns the current status of the authorization flow.
|
||||
pub async fn poll_device_flow(device_code: &str) -> DeviceFlowStatus {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return DeviceFlowStatus::Error(format!("HTTP client error: {e}")),
|
||||
};
|
||||
|
||||
let resp = match client
|
||||
.post(GITHUB_TOKEN_URL)
|
||||
.header("Accept", "application/json")
|
||||
.form(&[
|
||||
("client_id", COPILOT_CLIENT_ID),
|
||||
(
|
||||
"grant_type",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
),
|
||||
("device_code", device_code),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return DeviceFlowStatus::Error(format!("Token poll failed: {e}")),
|
||||
};
|
||||
|
||||
let body: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return DeviceFlowStatus::Error(format!("Failed to parse token response: {e}")),
|
||||
};
|
||||
|
||||
// Check for error field first (GitHub returns 200 with error during polling)
|
||||
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
|
||||
return match error {
|
||||
"authorization_pending" => DeviceFlowStatus::Pending,
|
||||
"slow_down" => {
|
||||
let interval = body
|
||||
.get("interval")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
DeviceFlowStatus::SlowDown {
|
||||
new_interval: interval,
|
||||
}
|
||||
}
|
||||
"expired_token" => DeviceFlowStatus::Expired,
|
||||
"access_denied" => DeviceFlowStatus::AccessDenied,
|
||||
_ => {
|
||||
let desc = body
|
||||
.get("error_description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(error);
|
||||
DeviceFlowStatus::Error(desc.to_string())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Success — extract access token
|
||||
if let Some(token) = body.get("access_token").and_then(|v| v.as_str()) {
|
||||
DeviceFlowStatus::Complete {
|
||||
access_token: Zeroizing::new(token.to_string()),
|
||||
}
|
||||
} else {
|
||||
DeviceFlowStatus::Error("No access_token in response".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_constants() {
|
||||
assert!(GITHUB_DEVICE_CODE_URL.starts_with("https://"));
|
||||
assert!(GITHUB_TOKEN_URL.starts_with("https://"));
|
||||
assert!(!COPILOT_CLIENT_ID.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -576,6 +576,7 @@ fn convert_message(msg: &Message) -> ApiMessage {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
..
|
||||
} => Some(ApiContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
content: content.clone(),
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Claude Code CLI backend driver.
|
||||
//!
|
||||
//! Spawns the `claude` CLI (Claude Code) as a subprocess in print mode (`-p`),
|
||||
//! which is non-interactive and handles its own authentication.
|
||||
//! This allows users with Claude Code installed to use it as an LLM provider
|
||||
//! without needing a separate API key.
|
||||
|
||||
use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent};
|
||||
use async_trait::async_trait;
|
||||
use openfang_types::message::{ContentBlock, Role, StopReason, TokenUsage};
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// LLM driver that delegates to the Claude Code CLI.
|
||||
pub struct ClaudeCodeDriver {
|
||||
cli_path: String,
|
||||
}
|
||||
|
||||
impl ClaudeCodeDriver {
|
||||
/// Create a new Claude Code driver.
|
||||
///
|
||||
/// `cli_path` overrides the CLI binary path; defaults to `"claude"` on PATH.
|
||||
pub fn new(cli_path: Option<String>) -> Self {
|
||||
Self {
|
||||
cli_path: cli_path
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "claude".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect if the Claude Code CLI is available on PATH.
|
||||
pub fn detect() -> Option<String> {
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a text prompt from the completion request messages.
|
||||
fn build_prompt(request: &CompletionRequest) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(ref sys) = request.system {
|
||||
parts.push(format!("[System]\n{sys}"));
|
||||
}
|
||||
|
||||
for msg in &request.messages {
|
||||
let role_label = match msg.role {
|
||||
Role::User => "User",
|
||||
Role::Assistant => "Assistant",
|
||||
Role::System => "System",
|
||||
};
|
||||
let text = msg.content.text_content();
|
||||
if !text.is_empty() {
|
||||
parts.push(format!("[{role_label}]\n{text}"));
|
||||
}
|
||||
}
|
||||
|
||||
parts.join("\n\n")
|
||||
}
|
||||
|
||||
/// Map a model ID like "claude-code/opus" to CLI --model flag value.
|
||||
fn model_flag(model: &str) -> Option<String> {
|
||||
let stripped = model
|
||||
.strip_prefix("claude-code/")
|
||||
.unwrap_or(model);
|
||||
match stripped {
|
||||
"opus" => Some("opus".to_string()),
|
||||
"sonnet" => Some("sonnet".to_string()),
|
||||
"haiku" => Some("haiku".to_string()),
|
||||
_ => Some(stripped.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON output from `claude -p --output-format json`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClaudeJsonOutput {
|
||||
result: Option<String>,
|
||||
#[serde(default)]
|
||||
usage: Option<ClaudeUsage>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// Usage stats from Claude CLI JSON output.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct ClaudeUsage {
|
||||
#[serde(default)]
|
||||
input_tokens: u64,
|
||||
#[serde(default)]
|
||||
output_tokens: u64,
|
||||
}
|
||||
|
||||
/// Stream JSON event from `claude -p --output-format stream-json`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClaudeStreamEvent {
|
||||
#[serde(default)]
|
||||
r#type: String,
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
result: Option<String>,
|
||||
#[serde(default)]
|
||||
usage: Option<ClaudeUsage>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for ClaudeCodeDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
let prompt = Self::build_prompt(&request);
|
||||
let model_flag = Self::model_flag(&request.model);
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&self.cli_path);
|
||||
cmd.arg("-p")
|
||||
.arg(&prompt)
|
||||
.arg("--output-format")
|
||||
.arg("json");
|
||||
|
||||
if let Some(ref model) = model_flag {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
// SECURITY: Don't inherit all env vars — only safe ones
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
debug!(cli = %self.cli_path, "Spawning Claude Code CLI");
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(format!("Failed to spawn claude CLI: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(LlmError::Api {
|
||||
status: output.status.code().unwrap_or(1) as u16,
|
||||
message: format!("Claude CLI failed: {stderr}"),
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Try JSON parse first
|
||||
if let Ok(parsed) = serde_json::from_str::<ClaudeJsonOutput>(&stdout) {
|
||||
let text = parsed.result.unwrap_or_default();
|
||||
let usage = parsed.usage.unwrap_or_default();
|
||||
return Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text: text.clone() }],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: TokenUsage {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: treat entire stdout as plain text
|
||||
let text = stdout.trim().to_string();
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text }],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: TokenUsage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: CompletionRequest,
|
||||
tx: tokio::sync::mpsc::Sender<StreamEvent>,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
let prompt = Self::build_prompt(&request);
|
||||
let model_flag = Self::model_flag(&request.model);
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&self.cli_path);
|
||||
cmd.arg("-p")
|
||||
.arg(&prompt)
|
||||
.arg("--output-format")
|
||||
.arg("stream-json");
|
||||
|
||||
if let Some(ref model) = model_flag {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
debug!(cli = %self.cli_path, "Spawning Claude Code CLI (streaming)");
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| LlmError::Http(format!("Failed to spawn claude CLI: {e}")))?;
|
||||
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| LlmError::Http("No stdout from claude CLI".to_string()))?;
|
||||
|
||||
let reader = tokio::io::BufReader::new(stdout);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
let mut full_text = String::new();
|
||||
let mut final_usage = TokenUsage {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
};
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<ClaudeStreamEvent>(&line) {
|
||||
Ok(event) => {
|
||||
match event.r#type.as_str() {
|
||||
"content" | "text" => {
|
||||
if let Some(ref content) = event.content {
|
||||
full_text.push_str(content);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta {
|
||||
text: content.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"result" | "done" | "complete" => {
|
||||
if let Some(ref result) = event.result {
|
||||
if full_text.is_empty() {
|
||||
full_text = result.clone();
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta {
|
||||
text: result.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Some(usage) = event.usage {
|
||||
final_usage = TokenUsage {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown event type — try content field as fallback
|
||||
if let Some(ref content) = event.content {
|
||||
full_text.push_str(content);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta {
|
||||
text: content.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Not valid JSON — treat as raw text
|
||||
warn!(line = %line, error = %e, "Non-JSON line from Claude CLI");
|
||||
full_text.push_str(&line);
|
||||
let _ = tx
|
||||
.send(StreamEvent::TextDelta { text: line })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to finish
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(format!("Claude CLI wait failed: {e}")))?;
|
||||
|
||||
if !status.success() {
|
||||
warn!(code = ?status.code(), "Claude CLI exited with error");
|
||||
}
|
||||
|
||||
let _ = tx
|
||||
.send(StreamEvent::ContentComplete {
|
||||
stop_reason: StopReason::EndTurn,
|
||||
usage: final_usage,
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text { text: full_text }],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: Vec::new(),
|
||||
usage: final_usage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the Claude Code CLI is available.
|
||||
pub fn claude_code_available() -> bool {
|
||||
ClaudeCodeDriver::detect().is_some()
|
||||
|| claude_credentials_exist()
|
||||
}
|
||||
|
||||
/// Check if Claude credentials file exists (~/.claude/.credentials.json).
|
||||
fn claude_credentials_exist() -> bool {
|
||||
if let Some(home) = home_dir() {
|
||||
home.join(".claude").join(".credentials.json").exists()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Cross-platform home directory.
|
||||
fn home_dir() -> Option<std::path::PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("USERPROFILE").ok().map(std::path::PathBuf::from)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var("HOME").ok().map(std::path::PathBuf::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt_simple() {
|
||||
use openfang_types::message::{Message, MessageContent};
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: "claude-code/sonnet".to_string(),
|
||||
messages: vec![Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::text("Hello"),
|
||||
}],
|
||||
tools: vec![],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
system: Some("You are helpful.".to_string()),
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
let prompt = ClaudeCodeDriver::build_prompt(&request);
|
||||
assert!(prompt.contains("[System]"));
|
||||
assert!(prompt.contains("You are helpful."));
|
||||
assert!(prompt.contains("[User]"));
|
||||
assert!(prompt.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_flag_mapping() {
|
||||
assert_eq!(
|
||||
ClaudeCodeDriver::model_flag("claude-code/opus"),
|
||||
Some("opus".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ClaudeCodeDriver::model_flag("claude-code/sonnet"),
|
||||
Some("sonnet".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ClaudeCodeDriver::model_flag("claude-code/haiku"),
|
||||
Some("haiku".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ClaudeCodeDriver::model_flag("custom-model"),
|
||||
Some("custom-model".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_defaults_to_claude() {
|
||||
let driver = ClaudeCodeDriver::new(None);
|
||||
assert_eq!(driver.cli_path, "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_with_custom_path() {
|
||||
let driver = ClaudeCodeDriver::new(Some("/usr/local/bin/claude".to_string()));
|
||||
assert_eq!(driver.cli_path, "/usr/local/bin/claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_with_empty_path() {
|
||||
let driver = ClaudeCodeDriver::new(Some(String::new()));
|
||||
assert_eq!(driver.cli_path, "claude");
|
||||
}
|
||||
}
|
||||
@@ -213,10 +213,17 @@ fn convert_messages(
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
ContentBlock::ToolResult {
|
||||
content, tool_name, ..
|
||||
} => {
|
||||
let fn_name = if tool_name.is_empty() {
|
||||
"unknown_function".to_string()
|
||||
} else {
|
||||
tool_name.clone()
|
||||
};
|
||||
parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponseData {
|
||||
name: String::new(),
|
||||
name: fn_name,
|
||||
response: serde_json::json!({ "result": content }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! Mistral, Fireworks, Ollama, vLLM, and any OpenAI-compatible endpoint.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod claude_code;
|
||||
pub mod copilot;
|
||||
pub mod fallback;
|
||||
pub mod gemini;
|
||||
@@ -132,6 +133,16 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "GITHUB_TOKEN",
|
||||
key_required: true,
|
||||
}),
|
||||
"codex" | "openai-codex" => Some(ProviderDefaults {
|
||||
base_url: OPENAI_BASE_URL,
|
||||
api_key_env: "OPENAI_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"claude-code" => Some(ProviderDefaults {
|
||||
base_url: "",
|
||||
api_key_env: "",
|
||||
key_required: false,
|
||||
}),
|
||||
"moonshot" | "kimi" => Some(ProviderDefaults {
|
||||
base_url: MOONSHOT_BASE_URL,
|
||||
api_key_env: "MOONSHOT_API_KEY",
|
||||
@@ -227,6 +238,31 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
return Ok(Arc::new(gemini::GeminiDriver::new(api_key, base_url)));
|
||||
}
|
||||
|
||||
// Codex — reuses OpenAI driver with credential sync from Codex CLI
|
||||
if provider == "codex" || provider == "openai-codex" {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
|
||||
.or_else(crate::model_catalog::read_codex_credential)
|
||||
.ok_or_else(|| {
|
||||
LlmError::MissingApiKey(
|
||||
"Set OPENAI_API_KEY or install Codex CLI".to_string(),
|
||||
)
|
||||
})?;
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| OPENAI_BASE_URL.to_string());
|
||||
return Ok(Arc::new(openai::OpenAIDriver::new(api_key, base_url)));
|
||||
}
|
||||
|
||||
// Claude Code CLI — subprocess-based, no API key needed
|
||||
if provider == "claude-code" {
|
||||
let cli_path = config.base_url.clone();
|
||||
return Ok(Arc::new(claude_code::ClaudeCodeDriver::new(cli_path)));
|
||||
}
|
||||
|
||||
// GitHub Copilot — wraps OpenAI-compatible driver with automatic token exchange.
|
||||
// The CopilotDriver exchanges the GitHub PAT for a Copilot API token on demand,
|
||||
// caches it, and refreshes when expired.
|
||||
@@ -287,8 +323,8 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
message: format!(
|
||||
"Unknown provider '{}'. Supported: anthropic, gemini, openai, groq, openrouter, \
|
||||
deepseek, together, mistral, fireworks, ollama, vllm, lmstudio, perplexity, \
|
||||
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot. \
|
||||
Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
cohere, ai21, cerebras, sambanova, huggingface, xai, replicate, github-copilot, \
|
||||
codex, claude-code. Or set base_url for a custom OpenAI-compatible endpoint.",
|
||||
provider
|
||||
),
|
||||
})
|
||||
@@ -324,6 +360,8 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"zhipu",
|
||||
"zhipu_coding",
|
||||
"qianfan",
|
||||
"codex",
|
||||
"claude-code",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -417,7 +455,9 @@ mod tests {
|
||||
assert!(providers.contains(&"zhipu"));
|
||||
assert!(providers.contains(&"zhipu_coding"));
|
||||
assert!(providers.contains(&"qianfan"));
|
||||
assert_eq!(providers.len(), 27);
|
||||
assert!(providers.contains(&"codex"));
|
||||
assert!(providers.contains(&"claude-code"));
|
||||
assert_eq!(providers.len(), 29);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod auth_cooldown;
|
||||
pub mod browser;
|
||||
pub mod command_lane;
|
||||
pub mod compactor;
|
||||
pub mod copilot_oauth;
|
||||
pub mod context_budget;
|
||||
pub mod context_overflow;
|
||||
pub mod docker_sandbox;
|
||||
|
||||
@@ -48,16 +48,28 @@ impl ModelCatalog {
|
||||
for provider in &mut self.providers {
|
||||
if !provider.key_required {
|
||||
provider.auth_status = AuthStatus::NotRequired;
|
||||
} else if std::env::var(&provider.api_key_env).is_ok() {
|
||||
provider.auth_status = AuthStatus::Configured;
|
||||
} else {
|
||||
// Special case: Gemini also accepts GOOGLE_API_KEY
|
||||
if provider.id == "gemini" && std::env::var("GOOGLE_API_KEY").is_ok() {
|
||||
provider.auth_status = AuthStatus::Configured;
|
||||
} else {
|
||||
provider.auth_status = AuthStatus::Missing;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Primary: check the provider's declared env var
|
||||
let has_key = std::env::var(&provider.api_key_env).is_ok();
|
||||
|
||||
// Secondary: provider-specific fallback auth
|
||||
let has_fallback = match provider.id.as_str() {
|
||||
"gemini" => std::env::var("GOOGLE_API_KEY").is_ok(),
|
||||
"codex" => {
|
||||
std::env::var("OPENAI_API_KEY").is_ok()
|
||||
|| read_codex_credential().is_some()
|
||||
}
|
||||
"claude-code" => crate::drivers::claude_code::claude_code_available(),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
provider.auth_status = if has_key || has_fallback {
|
||||
AuthStatus::Configured
|
||||
} else {
|
||||
AuthStatus::Missing
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +214,73 @@ impl ModelCatalog {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a custom model at runtime.
|
||||
///
|
||||
/// Returns `true` if the model was added, `false` if a model with that ID
|
||||
/// already exists (case-insensitive).
|
||||
pub fn add_custom_model(&mut self, entry: ModelCatalogEntry) -> bool {
|
||||
let lower = entry.id.to_lowercase();
|
||||
if self.models.iter().any(|m| m.id.to_lowercase() == lower) {
|
||||
return false;
|
||||
}
|
||||
let provider = entry.provider.clone();
|
||||
self.models.push(entry);
|
||||
|
||||
// Update provider model count
|
||||
if let Some(p) = self.providers.iter_mut().find(|p| p.id == provider) {
|
||||
p.model_count = self
|
||||
.models
|
||||
.iter()
|
||||
.filter(|m| m.provider == provider)
|
||||
.count();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove a custom model by ID.
|
||||
///
|
||||
/// Only removes models with `Custom` tier to prevent accidental deletion
|
||||
/// of builtin models. Returns `true` if removed.
|
||||
pub fn remove_custom_model(&mut self, model_id: &str) -> bool {
|
||||
let lower = model_id.to_lowercase();
|
||||
let before = self.models.len();
|
||||
self.models
|
||||
.retain(|m| !(m.id.to_lowercase() == lower && m.tier == ModelTier::Custom));
|
||||
self.models.len() < before
|
||||
}
|
||||
|
||||
/// Load custom models from a JSON file.
|
||||
///
|
||||
/// Merges them into the catalog. Skips models that already exist.
|
||||
pub fn load_custom_models(&mut self, path: &std::path::Path) {
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
let Ok(data) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = serde_json::from_str::<Vec<ModelCatalogEntry>>(&data) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries {
|
||||
self.add_custom_model(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save all custom-tier models to a JSON file.
|
||||
pub fn save_custom_models(&self, path: &std::path::Path) -> Result<(), String> {
|
||||
let custom: Vec<&ModelCatalogEntry> = self
|
||||
.models
|
||||
.iter()
|
||||
.filter(|m| m.tier == ModelTier::Custom)
|
||||
.collect();
|
||||
let json = serde_json::to_string_pretty(&custom)
|
||||
.map_err(|e| format!("Failed to serialize custom models: {e}"))?;
|
||||
std::fs::write(path, json)
|
||||
.map_err(|e| format!("Failed to write custom models file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ModelCatalog {
|
||||
@@ -210,6 +289,53 @@ impl Default for ModelCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an OpenAI API key from the Codex CLI credential file.
|
||||
///
|
||||
/// Checks `$CODEX_HOME/auth.json` or `~/.codex/auth.json`.
|
||||
/// Returns `Some(api_key)` if the file exists and contains a valid, non-expired token.
|
||||
/// Only checks presence — the actual key value is used transiently, never stored.
|
||||
pub fn read_codex_credential() -> Option<String> {
|
||||
let codex_home = std::env::var("CODEX_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("USERPROFILE")
|
||||
.ok()
|
||||
.map(|h| std::path::PathBuf::from(h).join(".codex"))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| std::path::PathBuf::from(h).join(".codex"))
|
||||
}
|
||||
})?;
|
||||
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
let content = std::fs::read_to_string(&auth_path).ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
|
||||
// Check expiry if present
|
||||
if let Some(expires_at) = parsed.get("expires_at").and_then(|v| v.as_i64()) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
if now >= expires_at {
|
||||
return None; // Expired
|
||||
}
|
||||
}
|
||||
|
||||
parsed
|
||||
.get("api_key")
|
||||
.or_else(|| parsed.get("token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builtin data
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -472,6 +598,26 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── OpenAI Codex ────────────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "codex".into(),
|
||||
display_name: "OpenAI Codex".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
base_url: OPENAI_BASE_URL.into(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── Claude Code CLI ─────────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "claude-code".into(),
|
||||
display_name: "Claude Code".into(),
|
||||
api_key_env: String::new(),
|
||||
base_url: String::new(),
|
||||
key_required: false,
|
||||
auth_status: AuthStatus::NotRequired,
|
||||
model_count: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -526,8 +672,19 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("glm", "glm-4-plus"),
|
||||
("ernie", "ernie-4.5-8k"),
|
||||
("kimi", "moonshot-v1-128k"),
|
||||
("minimax", "minimax-text-01"),
|
||||
("minimax", "MiniMax-M2.5"),
|
||||
("minimax-m2.5", "MiniMax-M2.5"),
|
||||
("minimax-m2.1", "MiniMax-M2.1"),
|
||||
("codegeex", "codegeex-4"),
|
||||
// Codex aliases
|
||||
("codex", "codex/gpt-4.1"),
|
||||
("codex-4.1", "codex/gpt-4.1"),
|
||||
("codex-o4", "codex/o4-mini"),
|
||||
// Claude Code aliases
|
||||
("claude-code", "claude-code/sonnet"),
|
||||
("claude-code-opus", "claude-code/opus"),
|
||||
("claude-code-sonnet", "claude-code/sonnet"),
|
||||
("claude-code-haiku", "claude-code/haiku"),
|
||||
];
|
||||
pairs
|
||||
.into_iter()
|
||||
@@ -1211,7 +1368,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// OpenRouter (5)
|
||||
// OpenRouter (11)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/auto".into(),
|
||||
@@ -1283,6 +1440,76 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/meta-llama/llama-3.3-70b-instruct".into(),
|
||||
display_name: "Llama 3.3 70B (OpenRouter, free)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/mistralai/mistral-7b-instruct".into(),
|
||||
display_name: "Mistral 7B (OpenRouter, free)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/google/gemma-2-9b-it".into(),
|
||||
display_name: "Gemma 2 9B (OpenRouter, free)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 8_192,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/qwen/qwen-2.5-72b-instruct".into(),
|
||||
display_name: "Qwen 2.5 72B (OpenRouter, free)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "openrouter/deepseek/deepseek-chat-v3-0324".into(),
|
||||
display_name: "DeepSeek V3 0324 (OpenRouter)".into(),
|
||||
provider: "openrouter".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 0.14,
|
||||
output_cost_per_m: 0.28,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Mistral (6)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -2242,7 +2469,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// MiniMax (3)
|
||||
// MiniMax (4)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "minimax-text-01".into(),
|
||||
@@ -2258,6 +2485,20 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec!["minimax".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "MiniMax-M2.5".into(),
|
||||
display_name: "MiniMax M2.5".into(),
|
||||
provider: "minimax".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 1.10,
|
||||
output_cost_per_m: 4.40,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["minimax-m2.5".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "MiniMax-M2.1".into(),
|
||||
display_name: "MiniMax M2.1".into(),
|
||||
@@ -2270,7 +2511,7 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
aliases: vec!["minimax-m2.1".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "abab6.5-chat".into(),
|
||||
@@ -2567,6 +2808,82 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// OpenAI Codex (2) — reuses OpenAI driver
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "codex/gpt-4.1".into(),
|
||||
display_name: "GPT-4.1 (Codex)".into(),
|
||||
provider: "codex".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 1_047_576,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 2.00,
|
||||
output_cost_per_m: 8.00,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["codex".into(), "codex-4.1".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "codex/o4-mini".into(),
|
||||
display_name: "o4-mini (Codex)".into(),
|
||||
provider: "codex".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 100_000,
|
||||
input_cost_per_m: 1.10,
|
||||
output_cost_per_m: 4.40,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["codex-o4".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Claude Code CLI (3) — subprocess-based
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "claude-code/opus".into(),
|
||||
display_name: "Claude Opus (CLI)".into(),
|
||||
provider: "claude-code".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 128_000,
|
||||
input_cost_per_m: 5.0,
|
||||
output_cost_per_m: 25.0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["claude-code-opus".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "claude-code/sonnet".into(),
|
||||
display_name: "Claude Sonnet (CLI)".into(),
|
||||
provider: "claude-code".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
input_cost_per_m: 3.0,
|
||||
output_cost_per_m: 15.0,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["claude-code".into(), "claude-code-sonnet".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "claude-code/haiku".into(),
|
||||
display_name: "Claude Haiku (CLI)".into(),
|
||||
provider: "claude-code".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.25,
|
||||
output_cost_per_m: 1.25,
|
||||
supports_tools: false,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["claude-code-haiku".into()],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2583,7 +2900,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_has_providers() {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(catalog.list_providers().len(), 28);
|
||||
assert_eq!(catalog.list_providers().len(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2817,6 +3134,14 @@ mod tests {
|
||||
assert!(catalog.find_model("codegeex").is_some());
|
||||
assert!(catalog.find_model("ernie").is_some());
|
||||
assert!(catalog.find_model("minimax").is_some());
|
||||
// MiniMax M2.5 — by exact ID, alias, and case-insensitive
|
||||
let m25 = catalog.find_model("MiniMax-M2.5").unwrap();
|
||||
assert_eq!(m25.provider, "minimax");
|
||||
assert_eq!(m25.tier, ModelTier::Frontier);
|
||||
assert!(catalog.find_model("minimax-m2.5").is_some());
|
||||
// Default "minimax" alias now points to M2.5
|
||||
let default = catalog.find_model("minimax").unwrap();
|
||||
assert_eq!(default.id, "MiniMax-M2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2871,4 +3196,54 @@ mod tests {
|
||||
LMSTUDIO_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_provider() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let codex = catalog.get_provider("codex").unwrap();
|
||||
assert_eq!(codex.display_name, "OpenAI Codex");
|
||||
assert_eq!(codex.api_key_env, "OPENAI_API_KEY");
|
||||
assert!(codex.key_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_models() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let models = catalog.models_by_provider("codex");
|
||||
assert_eq!(models.len(), 2);
|
||||
assert!(models.iter().any(|m| m.id == "codex/gpt-4.1"));
|
||||
assert!(models.iter().any(|m| m.id == "codex/o4-mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_aliases() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("codex").unwrap();
|
||||
assert_eq!(entry.id, "codex/gpt-4.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_provider() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let cc = catalog.get_provider("claude-code").unwrap();
|
||||
assert_eq!(cc.display_name, "Claude Code");
|
||||
assert!(!cc.key_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_models() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let models = catalog.models_by_provider("claude-code");
|
||||
assert_eq!(models.len(), 3);
|
||||
assert!(models.iter().any(|m| m.id == "claude-code/opus"));
|
||||
assert!(models.iter().any(|m| m.id == "claude-code/sonnet"));
|
||||
assert!(models.iter().any(|m| m.id == "claude-code/haiku"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_code_aliases() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("claude-code").unwrap();
|
||||
assert_eq!(entry.id, "claude-code/sonnet");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +370,7 @@ fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
|
||||
.or_default()
|
||||
.push(ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name: String::new(),
|
||||
content: "[Tool execution was interrupted or lost]".to_string(),
|
||||
is_error: true,
|
||||
});
|
||||
@@ -702,6 +703,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "orphan-id".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "some result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -762,6 +764,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Results found".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -793,6 +796,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-reorder".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Search results".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -881,6 +885,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-dup".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "First result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -889,6 +894,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-dup".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "Duplicate result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -978,6 +984,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "orphan".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "lost".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1057,6 +1064,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-a".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "search result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1066,6 +1074,7 @@ mod tests {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tu-ghost".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "ghost result".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
@@ -1114,11 +1123,13 @@ mod tests {
|
||||
content: MessageContent::Blocks(vec![
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: "orphan-1".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "lost 1".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id: "orphan-2".to_string(),
|
||||
tool_name: String::new(),
|
||||
content: "lost 2".to_string(),
|
||||
is_error: false,
|
||||
},
|
||||
|
||||
@@ -167,11 +167,19 @@ pub struct ApprovalResponse {
|
||||
#[serde(default)]
|
||||
pub struct ApprovalPolicy {
|
||||
/// Tools that always require approval. Default: `["shell_exec"]`.
|
||||
///
|
||||
/// Accepts either a list of tool names or a boolean shorthand:
|
||||
/// - `require_approval = false` → empty list (no tools require approval)
|
||||
/// - `require_approval = true` → `["shell_exec"]` (the default set)
|
||||
#[serde(deserialize_with = "deserialize_require_approval")]
|
||||
pub require_approval: Vec<String>,
|
||||
/// Timeout in seconds. Default: 60, range: 10..=300.
|
||||
pub timeout_secs: u64,
|
||||
/// Auto-approve in autonomous mode. Default: `false`.
|
||||
pub auto_approve_autonomous: bool,
|
||||
/// Alias: if `auto_approve = true`, clears the require list at boot.
|
||||
#[serde(default, alias = "auto_approve")]
|
||||
pub auto_approve: bool,
|
||||
}
|
||||
|
||||
impl Default for ApprovalPolicy {
|
||||
@@ -180,11 +188,57 @@ impl Default for ApprovalPolicy {
|
||||
require_approval: vec!["shell_exec".to_string()],
|
||||
timeout_secs: 60,
|
||||
auto_approve_autonomous: false,
|
||||
auto_approve: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom deserializer that accepts:
|
||||
/// - A list of strings: `["shell_exec", "file_write"]`
|
||||
/// - A boolean: `false` → `[]`, `true` → `["shell_exec"]`
|
||||
fn deserialize_require_approval<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct RequireApprovalVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for RequireApprovalVisitor {
|
||||
type Value = Vec<String>;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list of tool names or a boolean")
|
||||
}
|
||||
|
||||
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
|
||||
Ok(if v {
|
||||
vec!["shell_exec".to_string()]
|
||||
} else {
|
||||
vec![]
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
|
||||
let mut v = Vec::new();
|
||||
while let Some(s) = seq.next_element::<String>()? {
|
||||
v.push(s);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(RequireApprovalVisitor)
|
||||
}
|
||||
|
||||
impl ApprovalPolicy {
|
||||
/// Apply the `auto_approve` shorthand: if true, clears the require list.
|
||||
pub fn apply_shorthands(&mut self) {
|
||||
if self.auto_approve {
|
||||
self.require_approval.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate this policy's fields.
|
||||
///
|
||||
/// Returns `Ok(())` or an error message describing the first validation failure.
|
||||
@@ -485,6 +539,7 @@ mod tests {
|
||||
assert_eq!(policy.require_approval, vec!["shell_exec".to_string()]);
|
||||
assert_eq!(policy.timeout_secs, 60);
|
||||
assert!(!policy.auto_approve_autonomous);
|
||||
assert!(!policy.auto_approve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -496,6 +551,31 @@ mod tests {
|
||||
assert!(!policy.auto_approve_autonomous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_require_approval_bool_false() {
|
||||
// require_approval = false → empty list
|
||||
let policy: ApprovalPolicy =
|
||||
serde_json::from_str(r#"{"require_approval": false}"#).unwrap();
|
||||
assert!(policy.require_approval.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_require_approval_bool_true() {
|
||||
// require_approval = true → ["shell_exec"]
|
||||
let policy: ApprovalPolicy =
|
||||
serde_json::from_str(r#"{"require_approval": true}"#).unwrap();
|
||||
assert_eq!(policy.require_approval, vec!["shell_exec"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_auto_approve_clears_list() {
|
||||
let mut policy = ApprovalPolicy::default();
|
||||
assert!(!policy.require_approval.is_empty());
|
||||
policy.auto_approve = true;
|
||||
policy.apply_shorthands();
|
||||
assert!(policy.require_approval.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ApprovalPolicy — timeout_secs
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -608,6 +688,7 @@ mod tests {
|
||||
require_approval: vec!["shell_exec".into(), "file_delete".into()],
|
||||
timeout_secs: 120,
|
||||
auto_approve_autonomous: true,
|
||||
auto_approve: false,
|
||||
};
|
||||
let json = serde_json::to_string(&policy).unwrap();
|
||||
let back: ApprovalPolicy = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Shared wire types for the Agent Communication UI.
|
||||
//!
|
||||
//! These types are used by both the REST API and the TUI to represent
|
||||
//! agent topology graphs, inter-agent communication events, and
|
||||
//! request payloads for sending messages / posting tasks.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A node in the agent topology graph.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopoNode {
|
||||
/// Agent ID.
|
||||
pub id: String,
|
||||
/// Human-readable agent name.
|
||||
pub name: String,
|
||||
/// Current lifecycle state (e.g. "Running", "Suspended").
|
||||
pub state: String,
|
||||
/// Model name the agent is using.
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
/// An edge in the agent topology graph.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopoEdge {
|
||||
/// Source agent ID.
|
||||
pub from: String,
|
||||
/// Target agent ID.
|
||||
pub to: String,
|
||||
/// Relationship kind.
|
||||
pub kind: EdgeKind,
|
||||
}
|
||||
|
||||
/// The kind of relationship between two agents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EdgeKind {
|
||||
/// Parent spawned child.
|
||||
ParentChild,
|
||||
/// Peer-to-peer message exchange.
|
||||
Peer,
|
||||
}
|
||||
|
||||
/// The full agent topology: nodes + edges.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Topology {
|
||||
pub nodes: Vec<TopoNode>,
|
||||
pub edges: Vec<TopoEdge>,
|
||||
}
|
||||
|
||||
/// A communication event between agents.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommsEvent {
|
||||
/// Unique event ID.
|
||||
pub id: String,
|
||||
/// ISO-8601 timestamp.
|
||||
pub timestamp: String,
|
||||
/// Event kind.
|
||||
pub kind: CommsEventKind,
|
||||
/// Source agent ID.
|
||||
pub source_id: String,
|
||||
/// Source agent name.
|
||||
pub source_name: String,
|
||||
/// Target agent ID (empty for lifecycle events without a target).
|
||||
pub target_id: String,
|
||||
/// Target agent name.
|
||||
pub target_name: String,
|
||||
/// Human-readable detail text.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// The kind of inter-agent communication event.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommsEventKind {
|
||||
/// Agent-to-agent message.
|
||||
AgentMessage,
|
||||
/// A new agent was spawned.
|
||||
AgentSpawned,
|
||||
/// An agent was terminated.
|
||||
AgentTerminated,
|
||||
/// A task was posted to the queue.
|
||||
TaskPosted,
|
||||
/// A task was claimed by an agent.
|
||||
TaskClaimed,
|
||||
/// A task was completed.
|
||||
TaskCompleted,
|
||||
}
|
||||
|
||||
/// Request body for POST /api/comms/send.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommsSendRequest {
|
||||
pub from_agent_id: String,
|
||||
pub to_agent_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Request body for POST /api/comms/task.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommsTaskRequest {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub assigned_to: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn comms_event_kind_roundtrip() {
|
||||
let kind = CommsEventKind::AgentMessage;
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(json, "\"agent_message\"");
|
||||
let parsed: CommsEventKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_kind_roundtrip() {
|
||||
let kind = EdgeKind::ParentChild;
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(json, "\"parent_child\"");
|
||||
let parsed: EdgeKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topology_serialization() {
|
||||
let topo = Topology {
|
||||
nodes: vec![TopoNode {
|
||||
id: "a1".into(),
|
||||
name: "agent-1".into(),
|
||||
state: "Running".into(),
|
||||
model: "gpt-4".into(),
|
||||
}],
|
||||
edges: vec![TopoEdge {
|
||||
from: "a1".into(),
|
||||
to: "a2".into(),
|
||||
kind: EdgeKind::Peer,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&topo).unwrap();
|
||||
assert!(json.contains("\"agent-1\""));
|
||||
assert!(json.contains("\"peer\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comms_send_request_deser() {
|
||||
let json = r#"{"from_agent_id":"a","to_agent_id":"b","message":"hello"}"#;
|
||||
let req: CommsSendRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.from_agent_id, "a");
|
||||
assert_eq!(req.message, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comms_task_request_deser() {
|
||||
let json = r#"{"title":"t","description":"d"}"#;
|
||||
let req: CommsTaskRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.title, "t");
|
||||
assert!(req.assigned_to.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comms_task_request_with_assign() {
|
||||
let json = r#"{"title":"t","description":"d","assigned_to":"agent-x"}"#;
|
||||
let req: CommsTaskRequest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(req.assigned_to.as_deref(), Some("agent-x"));
|
||||
}
|
||||
}
|
||||
@@ -996,7 +996,7 @@ pub struct KernelConfig {
|
||||
#[serde(default)]
|
||||
pub webhook_triggers: Option<WebhookTriggerConfig>,
|
||||
/// Execution approval policy.
|
||||
#[serde(default)]
|
||||
#[serde(default, alias = "approval_policy")]
|
||||
pub approval: crate::approval::ApprovalPolicy,
|
||||
/// Cron scheduler max total jobs across all agents. Default: 500.
|
||||
#[serde(default = "default_max_cron_jobs")]
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod agent;
|
||||
pub mod approval;
|
||||
pub mod capability;
|
||||
pub mod comms;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
|
||||
@@ -66,6 +66,9 @@ pub enum ContentBlock {
|
||||
ToolResult {
|
||||
/// The tool_use ID this result corresponds to.
|
||||
tool_use_id: String,
|
||||
/// The tool name (for Gemini FunctionResponse). Empty for legacy sessions.
|
||||
#[serde(default)]
|
||||
tool_name: String,
|
||||
/// The result content.
|
||||
content: String,
|
||||
/// Whether the tool execution errored.
|
||||
|
||||
@@ -58,6 +58,8 @@ pub enum ModelTier {
|
||||
Fast,
|
||||
/// Local models (Ollama, vLLM, LM Studio).
|
||||
Local,
|
||||
/// User-defined custom models added at runtime.
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl fmt::Display for ModelTier {
|
||||
@@ -68,6 +70,7 @@ impl fmt::Display for ModelTier {
|
||||
ModelTier::Balanced => write!(f, "balanced"),
|
||||
ModelTier::Fast => write!(f, "fast"),
|
||||
ModelTier::Local => write!(f, "local"),
|
||||
ModelTier::Custom => write!(f, "custom"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +191,7 @@ mod tests {
|
||||
assert_eq!(ModelTier::Balanced.to_string(), "balanced");
|
||||
assert_eq!(ModelTier::Fast.to_string(), "fast");
|
||||
assert_eq!(ModelTier::Local.to_string(), "local");
|
||||
assert_eq!(ModelTier::Custom.to_string(), "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user