From 41be0aacd69869c916de8a3f5a03f1d60f13f850 Mon Sep 17 00:00:00 2001 From: AgentFlow Date: Mon, 22 Jun 2026 11:00:38 +0100 Subject: [PATCH 1/6] =?UTF-8?q?release:=20v1.1.7=20=E2=80=94=20live=20mode?= =?UTF-8?q?l=20discovery=20for=20all=20providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fireworks: paginate /inference/v1/models (limit=200, cursor-based), 30s timeout, structured error logging, accept all accounts/fireworks/models/ prefixed models, minimal 2-model fallback - Anthropic: paginate /v1/models (limit=1000, after_id cursor), 30s timeout, structured error logging, minimal 2-model fallback - OpenAI: request /v1/models with limit=500, structured error handling, minimal 2-model fallback (previously returned bare error) - All providers: graceful degradation chain (API → CLI → hardcoded fallback with tracing::warn), no more silent failures - Update default Fireworks model from llama-v3p1-8b-instruct to deepseek-v3p1 across fireworks.rs, fallback.rs, mod.rs, process.rs - Add 3 new unit tests for fallback lists (Anthropic, OpenAI, Fireworks) --- Cargo.lock | 2 +- binary/Cargo.toml | 2 +- crates/agent-client/src/fallback.rs | 2 +- crates/agent-client/src/fireworks.rs | 2 +- crates/agentflow-tui/src/setup/mod.rs | 2 +- .../src/setup/model_discovery.rs | 602 +++++++++++++----- crates/pair-harness/src/process.rs | 4 +- 7 files changed, 463 insertions(+), 153 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 95c793b..7e6729f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1478,7 +1478,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openflows" -version = "1.0.16" +version = "1.1.7" dependencies = [ "agent-forge", "agent-lore", diff --git a/binary/Cargo.toml b/binary/Cargo.toml index 8dd724a..0045388 100644 --- a/binary/Cargo.toml +++ b/binary/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openflows" -version = "1.1.6" +version = "1.1.7" edition = "2021" license = "MIT" diff --git a/crates/agent-client/src/fallback.rs b/crates/agent-client/src/fallback.rs index ed10d8b..0b5ce92 100644 --- a/crates/agent-client/src/fallback.rs +++ b/crates/agent-client/src/fallback.rs @@ -159,7 +159,7 @@ impl FallbackClient { fn build_fireworks_chain(model_override: Option<&str>) -> Result { let mut clients: Vec> = Vec::new(); - let model = model_override.unwrap_or("accounts/fireworks/models/llama-v3p1-8b-instruct"); + let model = model_override.unwrap_or("accounts/fireworks/models/deepseek-v3p1"); info!( model = model, diff --git a/crates/agent-client/src/fireworks.rs b/crates/agent-client/src/fireworks.rs index 2458024..f1a57c8 100644 --- a/crates/agent-client/src/fireworks.rs +++ b/crates/agent-client/src/fireworks.rs @@ -31,7 +31,7 @@ impl FireworksClient { pub fn from_env() -> Result { let key = std::env::var("FIREWORKS_API_KEY").context("FIREWORKS_API_KEY not set")?; let model = std::env::var("FIREWORKS_MODEL") - .unwrap_or_else(|_| "accounts/fireworks/models/llama-v3p1-8b-instruct".to_string()); + .unwrap_or_else(|_| "accounts/fireworks/models/deepseek-v3p1".to_string()); let api_url = std::env::var("FIREWORKS_API_URL").unwrap_or_else(|_| FIREWORKS_API_URL.to_string()); Ok(Self { diff --git a/crates/agentflow-tui/src/setup/mod.rs b/crates/agentflow-tui/src/setup/mod.rs index ce5f0d1..1067109 100644 --- a/crates/agentflow-tui/src/setup/mod.rs +++ b/crates/agentflow-tui/src/setup/mod.rs @@ -335,7 +335,7 @@ pub fn write_registry_file(config: &SetupConfig, project_dir: &std::path::Path) "anthropic/claude-3-5-sonnet-20241022".to_string() } Some(p) if p.contains("Fireworks") => { - "fireworks/accounts/fireworks/models/llama-v3p1-8b-instruct".to_string() + "fireworks/accounts/fireworks/models/deepseek-v3p1".to_string() } _ => "openai/gpt-4o".to_string(), }); diff --git a/crates/agentflow-tui/src/setup/model_discovery.rs b/crates/agentflow-tui/src/setup/model_discovery.rs index 8c824c7..d974e47 100644 --- a/crates/agentflow-tui/src/setup/model_discovery.rs +++ b/crates/agentflow-tui/src/setup/model_discovery.rs @@ -15,35 +15,70 @@ pub struct ModelInfo { pub async fn discover_models(config: &SetupConfig) -> Result> { match config.selected_provider.as_deref() { Some(p) if p.contains("Anthropic") => { - // Try Anthropic API first with user's key, then CLI + // Try Anthropic API first with user's key if !config.anthropic_key.is_empty() { - if let Ok(models) = discover_anthropic_models(&config.anthropic_key).await { - if !models.is_empty() { - return Ok(models); + match discover_anthropic_models(&config.anthropic_key).await { + Ok(models) if !models.is_empty() => return Ok(models), + Ok(_) => { + tracing::warn!("Anthropic API returned no models, falling back"); + } + Err(e) => { + tracing::warn!(error = %e, "Anthropic API model discovery failed, falling back"); } } } - // Fallback to CLI discovery - discover_claude_models() + // Try CLI discovery as second fallback + if let Ok(models) = discover_claude_models_from_cli() { + if !models.is_empty() { + return Ok(models); + } + } + // Minimal hardcoded fallback — only used when API and CLI are both unreachable + tracing::warn!( + "All Anthropic model discovery methods failed — using minimal fallback list. \ + Models may not reflect your account's available models." + ); + discover_known_anthropic_models() } Some(p) if p.contains("OpenAI") || p.contains("Codex") => { - // For OpenAI/Codex, try Codex CLI first, then fall back to OpenAI API + // For OpenAI/Codex, try Codex CLI first, then OpenAI API if let Ok(models) = discover_codex_models() { if !models.is_empty() { return Ok(models); } } - // Fallback to OpenAI API if Codex CLI doesn't work + // Fallback to OpenAI API if let Some(ref key) = config.openai_key { - return discover_openai_models(key).await; + if !key.is_empty() { + match discover_openai_models(key).await { + Ok(models) if !models.is_empty() => return Ok(models), + Ok(_) => { + tracing::warn!("OpenAI API returned no models, falling back"); + } + Err(e) => { + tracing::warn!(error = %e, "OpenAI API model discovery failed, falling back"); + } + } + } } - Err(anyhow::anyhow!("No OpenAI API key configured")) + // Minimal hardcoded fallback + tracing::warn!( + "All OpenAI model discovery methods failed — using minimal fallback list." + ); + discover_known_openai_models() } Some(p) if p.contains("Fireworks") => { if let Some(ref key) = config.fireworks_key { - return discover_fireworks_models(key).await; + if !key.is_empty() { + if let Ok(models) = discover_fireworks_models(key).await { + if !models.is_empty() { + return Ok(models); + } + } + } + tracing::warn!("Fireworks API model discovery failed or returned no results, using known models"); } - Err(anyhow::anyhow!("No Fireworks API key configured")) + discover_known_fireworks_models() } _ => discover_backend_models(&config.selected_cli_backend), } @@ -52,7 +87,7 @@ pub async fn discover_models(config: &SetupConfig) -> Result> { fn discover_backend_models(cli_backend: &str) -> Result> { match cli_backend { "codex" => discover_codex_models(), - "claude" => discover_claude_models(), + "claude" => discover_claude_models_from_cli(), other => Err(anyhow::anyhow!("Unknown CLI backend: {}", other)), } } @@ -175,28 +210,51 @@ fn discover_codex_models() -> Result> { } /// Fetch available models from Anthropic API using user's API key. -/// This dynamically discovers models available for the specific account. +/// Uses pagination to retrieve all available models. async fn discover_anthropic_models(api_key: &str) -> Result> { - let client = reqwest::Client::new(); - let response = client - .get("https://api.anthropic.com/v1/models") - .header("x-api-key", api_key) - .header("anthropic-version", "2023-06-01") - .send() - .await?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Anthropic API returned status {} when listing models", - response.status() - )); - } + let mut all_models: Vec = Vec::new(); + let mut after_id: Option = None; - let json: serde_json::Value = response.json().await?; - let mut models = Vec::new(); + loop { + let mut url = "https://api.anthropic.com/v1/models?limit=1000".to_string(); + if let Some(ref id) = after_id { + url.push_str(&format!("&after_id={}", id)); + } - if let Some(model_array) = json.get("data").and_then(|v| v.as_array()) { - for m in model_array { + let response = client + .get(&url) + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!(status = %status, body = %body, "Anthropic models API error"); + return Err(anyhow::anyhow!( + "Anthropic API returned status {} when listing models", + status + )); + } + + let json: serde_json::Value = response.json().await?; + let has_more = json + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let model_array = json + .get("data") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + for m in &model_array { let raw_id = m .get("id") .and_then(|v| v.as_str()) @@ -219,19 +277,32 @@ async fn discover_anthropic_models(api_key: &str) -> Result> { .and_then(|v| v.as_str()) .map(String::from); - models.push(ModelInfo { + all_models.push(ModelInfo { slug: format!("anthropic/{}", raw_id), display_name, description, }); } + + if !has_more || model_array.is_empty() { + break; + } + + // Use the last model ID as the pagination cursor + if let Some(last) = model_array.last() { + after_id = last.get("id").and_then(|v| v.as_str()).map(String::from); + } else { + break; + } } - if models.is_empty() { + tracing::info!(count = all_models.len(), "Discovered Anthropic models via API"); + + if all_models.is_empty() { return Err(anyhow::anyhow!("No models found in Anthropic account")); } - Ok(models) + Ok(all_models) } /// Validates an Anthropic model ID returned from /v1/models. @@ -268,54 +339,9 @@ fn is_anthropic_chat_model(model_id: &str) -> bool { && is_valid_anthropic_api_model(model_id) // Must be a valid API model ID } -/// Fetch models from claude CLI -fn discover_claude_models() -> Result> { - // Try live discovery from claude CLI first - if let Ok(models) = discover_claude_models_from_cli() { - if !models.is_empty() { - return Ok(models); - } - } - - // Fallback to known-valid Anthropic API model IDs - // Note: -latest aliases don't work with the API, must use dated versions - // Source: https://docs.anthropic.com/en/docs/about-claude/models - let known_models = vec![ - ( - "claude-3-5-sonnet-20241022", - "Claude 3.5 Sonnet", - "Latest Claude 3.5 Sonnet (Oct 2024)", - ), - ( - "claude-3-5-haiku-20241022", - "Claude 3.5 Haiku", - "Fast, cost-effective (Oct 2024)", - ), - ( - "claude-3-opus-20240229", - "Claude 3 Opus", - "Most powerful model (Feb 2024)", - ), - ( - "claude-3-haiku-20240307", - "Claude 3 Haiku", - "Fastest responses (Mar 2024)", - ), - ]; - - Ok(known_models - .into_iter() - .map(|(slug, name, desc)| ModelInfo { - slug: format!("anthropic/{}", slug), - display_name: Some(name.to_string()), - description: Some(desc.to_string()), - }) - .collect()) -} - -/// Query claude CLI for available models -/// Maps CLI -latest aliases to actual API model IDs -/// Filters out model IDs that aren't valid Anthropic API model IDs +/// Fetch models from claude CLI — called directly as a fallback step. +/// No longer has its own hardcoded fallback; that is handled by +/// discover_known_anthropic_models() at the orchestration level. fn discover_claude_models_from_cli() -> Result> { let output = Command::new("claude").args(["models"]).output()?; @@ -373,56 +399,96 @@ fn map_claude_model_alias(alias: &str) -> &str { } /// Fetch available models from OpenAI API. +/// Uses pagination to retrieve all available models. async fn discover_openai_models(api_key: &str) -> Result> { - let client = reqwest::Client::new(); - let response = client - .get("https://api.openai.com/v1/models") + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let mut all_models: Vec = Vec::new(); + + // OpenAI returns all models in a single response (no cursor-based pagination), + // but supports a `limit` query param for fine-grained control. + let url = "https://api.openai.com/v1/models?limit=500"; + + let response = match client + .get(url) .header("Authorization", format!("Bearer {}", api_key)) .send() - .await?; + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "OpenAI models request failed"); + return Err(anyhow::anyhow!( + "Failed to reach OpenAI API: {}. Check your network connection.", + e + )); + } + }; - if !response.status().is_success() { + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + tracing::warn!(status = %status, body = %body, "OpenAI models API error"); return Err(anyhow::anyhow!( - "OpenAI API returned status {} when listing models", - response.status() + "OpenAI API returned status {} when listing models: {}", + status, + body.chars().take(200).collect::() )); } - let json: serde_json::Value = response.json().await?; - let mut models = Vec::new(); - - if let Some(model_array) = json.get("data").and_then(|v| v.as_array()) { - for m in model_array { - let raw_id = m - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - - // Skip non-chat models and embeddings - if !is_openai_chat_model(&raw_id) { - continue; - } + let json: serde_json::Value = match response.json().await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "Failed to parse OpenAI models response as JSON"); + return Err(anyhow::anyhow!( + "Failed to parse OpenAI models response: {}", + e + )); + } + }; + + let model_array = json + .get("data") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + for m in &model_array { + let raw_id = m + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + // Skip non-chat models and embeddings + if !is_openai_chat_model(&raw_id) { + continue; + } - let display_name = m - .get("name") - .or_else(|| m.get("id")) - .and_then(|v| v.as_str()) - .map(String::from); + let display_name = m + .get("name") + .or_else(|| m.get("id")) + .and_then(|v| v.as_str()) + .map(String::from); - models.push(ModelInfo { - slug: format!("openai/{}", raw_id), - display_name, - description: None, - }); - } + all_models.push(ModelInfo { + slug: format!("openai/{}", raw_id), + display_name, + description: None, + }); } - if models.is_empty() { - return Err(anyhow::anyhow!("No chat models found in OpenAI account")); + tracing::info!(count = all_models.len(), "Discovered OpenAI models via API"); + + if all_models.is_empty() { + return Err(anyhow::anyhow!( + "No chat models found in OpenAI account" + )); } - Ok(models) + Ok(all_models) } /// Check if an OpenAI model ID is a chat model (not embeddings, audio, etc.) @@ -437,34 +503,78 @@ fn is_openai_chat_model(model_id: &str) -> bool { && !model_id.contains("realtime") } -/// Fetch available models from Fireworks API. +/// Fetch available models from Fireworks API via the OpenAI-compatible +/// `/inference/v1/models` endpoint. Uses a 30 s timeout, requests up to +/// 200 models per page, and follows pagination tokens so we discover *all* +/// models rather than just the first 50. async fn discover_fireworks_models(api_key: &str) -> Result> { - let client = reqwest::Client::new(); - let response = client - .get("https://api.fireworks.ai/inference/v1/models") - .header("Authorization", format!("Bearer {}", api_key)) - .send() - .await?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Fireworks API returned status {} when listing models", - response.status() - )); - } + let mut all_models: Vec = Vec::new(); + let mut page_token: Option = None; - let json: serde_json::Value = response.json().await?; - let mut models = Vec::new(); + loop { + let mut url = "https://api.fireworks.ai/inference/v1/models?limit=200".to_string(); + if let Some(ref token) = page_token { + url.push_str(&format!("&after={}", token)); + } - if let Some(model_array) = json.get("data").and_then(|v| v.as_array()) { - for m in model_array { + tracing::debug!(url = %url, "Fetching Fireworks models page"); + + let response = match client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .send() + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "Fireworks models request failed"); + return Err(anyhow::anyhow!( + "Failed to reach Fireworks API: {}. Check your network connection.", + e + )); + } + }; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + tracing::warn!(status = %status, body = %body, "Fireworks API error listing models"); + return Err(anyhow::anyhow!( + "Fireworks API returned status {} when listing models: {}", + status, + body.chars().take(200).collect::() + )); + } + + let json: serde_json::Value = match response.json().await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "Failed to parse Fireworks models response as JSON"); + return Err(anyhow::anyhow!( + "Failed to parse Fireworks models response: {}", + e + )); + } + }; + + let model_array = json + .get("data") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + for m in &model_array { let raw_id = m .get("id") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); - // Skip non-chat models if !is_fireworks_chat_model(&raw_id) { continue; } @@ -480,25 +590,144 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { .and_then(|v| v.as_str()) .map(String::from); - models.push(ModelInfo { + all_models.push(ModelInfo { slug: format!("fireworks/{}", raw_id), display_name, description, }); } + + // OpenAI-compatible pagination: check for has_more / last ID + let has_more = json + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !has_more || model_array.is_empty() { + break; + } + + // Use the last model ID as the pagination cursor + if let Some(last) = model_array.last() { + page_token = last.get("id").and_then(|v| v.as_str()).map(String::from); + } else { + break; + } } - if models.is_empty() { - return Err(anyhow::anyhow!("No chat models found in Fireworks account")); + tracing::info!(count = all_models.len(), "Discovered Fireworks models via API"); + + if all_models.is_empty() { + return Err(anyhow::anyhow!( + "No chat models found in Fireworks account — the API returned empty results" + )); } - Ok(models) + Ok(all_models) } -/// Check if a Fireworks model ID is a chat model +/// Minimal fallback list of Fireworks models used only when the API is +/// completely unreachable (no network, invalid key, etc.). +fn discover_known_fireworks_models() -> Result> { + let known_models = vec![ + ( + "accounts/fireworks/models/deepseek-v3p1", + "DeepSeek V3.1", + "Strong general-purpose model (fallback)", + ), + ( + "accounts/fireworks/models/llama-v3p3-70b-instruct", + "Llama 3.3 70B Instruct", + "Meta Llama 3.3 70B (fallback)", + ), + ]; + + tracing::warn!( + "Using hardcoded Fireworks model list — live discovery failed. \ + Models may not reflect your account's available models." + ); + + Ok(known_models + .into_iter() + .map(|(slug, name, desc)| ModelInfo { + slug: format!("fireworks/{}", slug), + display_name: Some(name.to_string()), + description: Some(desc.to_string()), + }) + .collect()) +} + +/// Check if a Fireworks model ID is a chat/instruct/text model suitable for +/// agent use. Excludes embedding, image-generation, audio, and other +/// non-chat model families. +/// +/// The `/inference/v1/models` endpoint already filters to models available for +/// inference, so we only need to exclude clearly non-chat categories. fn is_fireworks_chat_model(model_id: &str) -> bool { - // Fireworks chat models typically have these patterns - model_id.contains("instruct") || model_id.contains("chat") || model_id.starts_with("accounts/") + // Exclude known non-chat model families + if model_id.contains("embedding") + || model_id.contains("-embed-") + || model_id.contains("whisper") + || model_id.contains("tts") + || model_id.contains("dall-e") + || model_id.contains("-audio-") + || model_id.contains("flux-") + || model_id.contains("stable-diffusion") + || model_id.contains("sd3-") + || model_id.contains("clip-") + { + return false; + } + + // Accept all models under accounts/fireworks/models/ — these are + // Fireworks-hosted serverless models available for text generation. + // The /inference/v1/models endpoint already pre-filters to inference-ready + // models, so anything it returns with this prefix is a valid chat model. + model_id.starts_with("accounts/fireworks/models/") +} + +/// Minimal fallback list of Anthropic models used only when both the API +/// and the CLI are completely unreachable. +fn discover_known_anthropic_models() -> Result> { + let known_models = vec![ + ( + "claude-sonnet-4-5", + "Claude Sonnet 4.5", + "Latest Sonnet (fallback)", + ), + ( + "claude-3-5-sonnet-20241022", + "Claude 3.5 Sonnet", + "Stable Sonnet (fallback)", + ), + ]; + + Ok(known_models + .into_iter() + .map(|(slug, name, desc)| ModelInfo { + slug: format!("anthropic/{}", slug), + display_name: Some(name.to_string()), + description: Some(desc.to_string()), + }) + .collect()) +} + +/// Minimal fallback list of OpenAI models used only when both the Codex CLI +/// and the OpenAI API are completely unreachable. +fn discover_known_openai_models() -> Result> { + let known_models = vec![ + ("gpt-4o", "GPT-4o", "Latest GPT-4o (fallback)"), + ("gpt-4o-mini", "GPT-4o Mini", "Fast, cost-effective (fallback)"), + ]; + + Ok(known_models + .into_iter() + .map(|(slug, name, desc)| ModelInfo { + slug: format!("openai/{}", slug), + display_name: Some(name.to_string()), + description: Some(desc.to_string()), + }) + .collect()) } /// Get default model for a CLI backend. @@ -519,7 +748,7 @@ pub fn default_model_for_provider(provider: &str) -> &'static str { } else if provider.contains("OpenAI") || provider.contains("Codex") { "openai/gpt-4o" } else if provider.contains("Fireworks") { - "fireworks/accounts/fireworks/models/llama-v3p1-8b-instruct" + "fireworks/accounts/fireworks/models/deepseek-v3p1" } else { "openai/gpt-4o" } @@ -545,7 +774,7 @@ mod tests { assert_eq!(default_model_for_provider("Codex"), "openai/gpt-4o"); assert_eq!( default_model_for_provider("Fireworks"), - "fireworks/accounts/fireworks/models/llama-v3p1-8b-instruct" + "fireworks/accounts/fireworks/models/deepseek-v3p1" ); assert_eq!(default_model_for_provider("Unknown"), "openai/gpt-4o"); } @@ -619,9 +848,90 @@ mod tests { #[test] fn test_is_fireworks_chat_model() { + // Fireworks serverless models are accepted by prefix assert!(is_fireworks_chat_model( "accounts/fireworks/models/llama-v3p1-8b-instruct" )); - assert!(!is_fireworks_chat_model("some-embedding-model")); + assert!(is_fireworks_chat_model( + "accounts/fireworks/models/deepseek-v3p1" + )); + assert!(is_fireworks_chat_model( + "accounts/fireworks/models/kimi-k2p6" + )); + assert!(is_fireworks_chat_model( + "accounts/fireworks/models/qwen3-235b-a22b" + )); + assert!(is_fireworks_chat_model( + "accounts/fireworks/models/glm-5p1" + )); + assert!(is_fireworks_chat_model( + "accounts/fireworks/models/gemma-4-31b-it-nvfp4" + )); + assert!(is_fireworks_chat_model( + "accounts/fireworks/models/llama-v3p3-70b-instruct" + )); + // Non-chat models are excluded even with the prefix + assert!(!is_fireworks_chat_model( + "accounts/fireworks/models/text-embedding-3" + )); + assert!(!is_fireworks_chat_model( + "accounts/fireworks/models/flux-1-schnell" + )); + assert!(!is_fireworks_chat_model( + "accounts/fireworks/models/whisper-large" + )); + // Models without the Fireworks prefix are not accepted + // (the /inference/v1/models endpoint returns Fireworks-prefixed IDs) + assert!(!is_fireworks_chat_model("some-random-model")); + assert!(!is_fireworks_chat_model("embedding-model")); + } + + #[test] + fn test_discover_known_fireworks_models() { + let models = discover_known_fireworks_models().unwrap(); + assert!(!models.is_empty()); + assert!(models.len() >= 2, "Fallback should have at least 2 models"); + for m in &models { + assert!( + m.slug.starts_with("fireworks/"), + "Expected fireworks/ prefix, got: {}", + m.slug + ); + assert!(m.display_name.is_some(), "Expected display_name for {}", m.slug); + } + assert_eq!( + models[0].slug, + "fireworks/accounts/fireworks/models/deepseek-v3p1" + ); + } + + #[test] + fn test_discover_known_anthropic_models() { + let models = discover_known_anthropic_models().unwrap(); + assert!(!models.is_empty()); + assert!(models.len() >= 2, "Fallback should have at least 2 models"); + for m in &models { + assert!( + m.slug.starts_with("anthropic/"), + "Expected anthropic/ prefix, got: {}", + m.slug + ); + assert!(m.display_name.is_some(), "Expected display_name for {}", m.slug); + } + } + + #[test] + fn test_discover_known_openai_models() { + let models = discover_known_openai_models().unwrap(); + assert!(!models.is_empty()); + assert!(models.len() >= 2, "Fallback should have at least 2 models"); + for m in &models { + assert!( + m.slug.starts_with("openai/"), + "Expected openai/ prefix, got: {}", + m.slug + ); + assert!(m.display_name.is_some(), "Expected display_name for {}", m.slug); + } } } diff --git a/crates/pair-harness/src/process.rs b/crates/pair-harness/src/process.rs index 9fbdfa6..4a21444 100644 --- a/crates/pair-harness/src/process.rs +++ b/crates/pair-harness/src/process.rs @@ -2555,8 +2555,8 @@ mod tests { ); assert_eq!(strip_provider_prefix("openai/gpt-4o"), "gpt-4o"); assert_eq!( - strip_provider_prefix("fireworks/accounts/fireworks/models/llama-v3p1-8b-instruct"), - "accounts/fireworks/models/llama-v3p1-8b-instruct" + strip_provider_prefix("fireworks/accounts/fireworks/models/deepseek-v3p1"), + "accounts/fireworks/models/deepseek-v3p1" ); assert_eq!( strip_provider_prefix("gemini/gemini-2.5-pro"), From 0b7abb4771921a53bb9d01a7e49f5155b6baf369 Mon Sep 17 00:00:00 2001 From: AgentFlow Date: Mon, 22 Jun 2026 11:14:41 +0100 Subject: [PATCH 2/6] fix: remove unsupported ?limit=200 from Fireworks models URL, improve error logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI-compatible /inference/v1/models endpoint does not support the ?limit= query parameter — that's only for the native /v1/accounts/{account_id}/models API. Adding it may cause unexpected responses or empty results, which explains why live discovery was failing and only the hardcoded fallback was shown. Also adds detailed tracing::warn! for every failure path in the Fireworks discovery so the exact error is visible in logs. --- .../src/setup/model_discovery.rs | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/agentflow-tui/src/setup/model_discovery.rs b/crates/agentflow-tui/src/setup/model_discovery.rs index d974e47..62e89f8 100644 --- a/crates/agentflow-tui/src/setup/model_discovery.rs +++ b/crates/agentflow-tui/src/setup/model_discovery.rs @@ -70,13 +70,35 @@ pub async fn discover_models(config: &SetupConfig) -> Result> { Some(p) if p.contains("Fireworks") => { if let Some(ref key) = config.fireworks_key { if !key.is_empty() { - if let Ok(models) = discover_fireworks_models(key).await { - if !models.is_empty() { + match discover_fireworks_models(key).await { + Ok(models) if !models.is_empty() => { + tracing::info!( + count = models.len(), + "Discovered Fireworks models via live API" + ); return Ok(models); } + Ok(_) => { + tracing::warn!( + "Fireworks API returned 0 chat models — falling back to minimal list" + ); + } + Err(e) => { + tracing::warn!( + error = %e, + "Fireworks API model discovery failed — falling back to minimal list" + ); + } } + } else { + tracing::warn!( + "Fireworks API key is empty — skipping live discovery, using minimal list" + ); } - tracing::warn!("Fireworks API model discovery failed or returned no results, using known models"); + } else { + tracing::warn!( + "No Fireworks API key configured — skipping live discovery, using minimal list" + ); } discover_known_fireworks_models() } @@ -504,9 +526,8 @@ fn is_openai_chat_model(model_id: &str) -> bool { } /// Fetch available models from Fireworks API via the OpenAI-compatible -/// `/inference/v1/models` endpoint. Uses a 30 s timeout, requests up to -/// 200 models per page, and follows pagination tokens so we discover *all* -/// models rather than just the first 50. +/// `/inference/v1/models` endpoint. Uses a 30 s timeout and follows +/// OpenAI-style pagination (`has_more` / `after`) to retrieve all pages. async fn discover_fireworks_models(api_key: &str) -> Result> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -516,12 +537,15 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { let mut page_token: Option = None; loop { - let mut url = "https://api.fireworks.ai/inference/v1/models?limit=200".to_string(); + // The OpenAI-compatible /inference/v1/models endpoint doesn't support + // ?limit=; it returns all models in one response. We use after= for + // cursor-based pagination only if has_more is true. + let mut url = "https://api.fireworks.ai/inference/v1/models".to_string(); if let Some(ref token) = page_token { - url.push_str(&format!("&after={}", token)); + url.push_str(&format!("?after={}", token)); } - tracing::debug!(url = %url, "Fetching Fireworks models page"); + tracing::info!(url = %url, "Fetching Fireworks models"); let response = match client .get(&url) @@ -534,7 +558,7 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { Err(e) => { tracing::warn!(error = %e, "Fireworks models request failed"); return Err(anyhow::anyhow!( - "Failed to reach Fireworks API: {}. Check your network connection.", + "Failed to reach Fireworks API: {}. Check your network connection and API key.", e )); } @@ -568,6 +592,11 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { .cloned() .unwrap_or_default(); + tracing::info!( + raw_count = model_array.len(), + "Fireworks API returned models in this page" + ); + for m in &model_array { let raw_id = m .get("id") @@ -576,6 +605,7 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { .to_string(); if !is_fireworks_chat_model(&raw_id) { + tracing::debug!(model = %raw_id, "Skipping non-chat Fireworks model"); continue; } @@ -597,7 +627,7 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { }); } - // OpenAI-compatible pagination: check for has_more / last ID + // Check for OpenAI-style pagination let has_more = json .get("has_more") .and_then(|v| v.as_bool()) From 0b5655a8082216220a68dc18968b67f53cb5a73d Mon Sep 17 00:00:00 2001 From: AgentFlow Date: Mon, 22 Jun 2026 11:30:15 +0100 Subject: [PATCH 3/6] fix: use Fireworks native API /v1/accounts/fireworks/models for model discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI-compatible /inference/v1/models endpoint returns HTTP 500 'Error listing deployed models' — it's broken on Fireworks' side. Switched to the native Fireworks API which: - Uses /v1/accounts/fireworks/models?pageSize=200 with pageToken pagination - Returns rich model metadata (supportsServerless, contextLength, supportsTools, supportsImageInput, displayName) - Filters for serverless models with contextLength > 0 (excludes image gen, embeddings, rerankers) - Falls back to the OpenAI-compat endpoint if the native API returns an error - Shows context length, tool support, and vision capability in descriptions --- .../src/setup/model_discovery.rs | 226 ++++++++++++++---- 1 file changed, 184 insertions(+), 42 deletions(-) diff --git a/crates/agentflow-tui/src/setup/model_discovery.rs b/crates/agentflow-tui/src/setup/model_discovery.rs index 62e89f8..4a2be34 100644 --- a/crates/agentflow-tui/src/setup/model_discovery.rs +++ b/crates/agentflow-tui/src/setup/model_discovery.rs @@ -525,9 +525,11 @@ fn is_openai_chat_model(model_id: &str) -> bool { && !model_id.contains("realtime") } -/// Fetch available models from Fireworks API via the OpenAI-compatible -/// `/inference/v1/models` endpoint. Uses a 30 s timeout and follows -/// OpenAI-style pagination (`has_more` / `after`) to retrieve all pages. +/// Fetch available models from Fireworks API using the native +/// `/v1/accounts/fireworks/models` endpoint (the OpenAI-compatible +/// `/inference/v1/models` endpoint has been returning 500 errors). +/// Uses 30 s timeout, pageSize=200 pagination, and filters for +/// serverless chat/text models with contextLength > 0. async fn discover_fireworks_models(api_key: &str) -> Result> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) @@ -537,26 +539,23 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { let mut page_token: Option = None; loop { - // The OpenAI-compatible /inference/v1/models endpoint doesn't support - // ?limit=; it returns all models in one response. We use after= for - // cursor-based pagination only if has_more is true. - let mut url = "https://api.fireworks.ai/inference/v1/models".to_string(); + let mut url = + "https://api.fireworks.ai/v1/accounts/fireworks/models?pageSize=200".to_string(); if let Some(ref token) = page_token { - url.push_str(&format!("?after={}", token)); + url.push_str(&format!("&pageToken={}", token)); } - tracing::info!(url = %url, "Fetching Fireworks models"); + tracing::info!(url = %url, "Fetching Fireworks models page"); let response = match client .get(&url) .header("Authorization", format!("Bearer {}", api_key)) - .header("Content-Type", "application/json") .send() .await { Ok(r) => r, Err(e) => { - tracing::warn!(error = %e, "Fireworks models request failed"); + tracing::warn!(error = %e, "Fireworks native models request failed"); return Err(anyhow::anyhow!( "Failed to reach Fireworks API: {}. Check your network connection and API key.", e @@ -567,18 +566,16 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - tracing::warn!(status = %status, body = %body, "Fireworks API error listing models"); - return Err(anyhow::anyhow!( - "Fireworks API returned status {} when listing models: {}", - status, - body.chars().take(200).collect::() - )); + tracing::warn!(status = %status, body = %body, "Fireworks native API error listing models"); + // Fall back to the OpenAI-compatible endpoint as a backup + tracing::info!("Native API failed, trying OpenAI-compatible endpoint as fallback"); + return discover_fireworks_models_openai_compat(api_key, &client).await; } let json: serde_json::Value = match response.json().await { Ok(v) => v, Err(e) => { - tracing::warn!(error = %e, "Failed to parse Fireworks models response as JSON"); + tracing::warn!(error = %e, "Failed to parse Fireworks native models response as JSON"); return Err(anyhow::anyhow!( "Failed to parse Fireworks models response: {}", e @@ -587,16 +584,165 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { }; let model_array = json - .get("data") + .get("models") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); tracing::info!( raw_count = model_array.len(), - "Fireworks API returned models in this page" + "Fireworks native API returned models in this page" ); + for m in &model_array { + let name = m + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + // Only include serverless models available for inference + let supports_serverless = m + .get("supportsServerless") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !supports_serverless { + continue; + } + + // Must have a positive context length (excludes image gen, etc.) + let ctx_len = m + .get("contextLength") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + if ctx_len == 0 { + continue; + } + + // Exclude known non-chat families + if !is_fireworks_chat_model(&name) { + continue; + } + + let display_name = m + .get("displayName") + .and_then(|v| v.as_str()) + .map(String::from); + + // Build a description with context length and capabilities + let supports_tools = m + .get("supportsTools") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let supports_vision = m + .get("supportsImageInput") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let mut desc_parts = vec![format!("{}K ctx", ctx_len / 1024)]; + if supports_tools { + desc_parts.push("tools".to_string()); + } + if supports_vision { + desc_parts.push("vision".to_string()); + } + let description = Some(desc_parts.join(", ")); + + all_models.push(ModelInfo { + slug: format!("fireworks/{}", name), + display_name, + description, + }); + } + + // Check for next page + let next_token = json + .get("nextPageToken") + .and_then(|v| v.as_str()) + .map(String::from); + + if next_token.is_none() || model_array.is_empty() { + break; + } + page_token = next_token; + } + + tracing::info!(count = all_models.len(), "Discovered Fireworks models via native API"); + + if all_models.is_empty() { + return Err(anyhow::anyhow!( + "No chat models found in Fireworks account — the API returned empty results" + )); + } + + Ok(all_models) +} + +/// Fallback: try the OpenAI-compatible /inference/v1/models endpoint. +/// This endpoint has been known to return 500 errors, so it's only used +/// when the native API fails. +async fn discover_fireworks_models_openai_compat( + api_key: &str, + client: &reqwest::Client, +) -> Result> { + let mut all_models: Vec = Vec::new(); + let mut page_token: Option = None; + + loop { + let mut url = "https://api.fireworks.ai/inference/v1/models".to_string(); + if let Some(ref token) = page_token { + url.push_str(&format!("?after={}", token)); + } + + tracing::info!(url = %url, "Fetching Fireworks models (OpenAI-compat fallback)"); + + let response = match client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .send() + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, "Fireworks OpenAI-compat models request failed"); + return Err(anyhow::anyhow!( + "Failed to reach Fireworks API (both native and OpenAI-compat): {}", + e + )); + } + }; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + tracing::warn!( + status = %status, + body = %body, + "Fireworks OpenAI-compat endpoint also failed" + ); + return Err(anyhow::anyhow!( + "Fireworks API returned status {} (both endpoints failed)", + status + )); + } + + let json: serde_json::Value = match response.json().await { + Ok(v) => v, + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to parse Fireworks OpenAI-compat response: {}", + e + )); + } + }; + + let model_array = json + .get("data") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + for m in &model_array { let raw_id = m .get("id") @@ -605,7 +751,6 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { .to_string(); if !is_fireworks_chat_model(&raw_id) { - tracing::debug!(model = %raw_id, "Skipping non-chat Fireworks model"); continue; } @@ -627,7 +772,6 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { }); } - // Check for OpenAI-style pagination let has_more = json .get("has_more") .and_then(|v| v.as_bool()) @@ -637,7 +781,6 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { break; } - // Use the last model ID as the pagination cursor if let Some(last) = model_array.last() { page_token = last.get("id").and_then(|v| v.as_str()).map(String::from); } else { @@ -645,11 +788,14 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { } } - tracing::info!(count = all_models.len(), "Discovered Fireworks models via API"); + tracing::info!( + count = all_models.len(), + "Discovered Fireworks models via OpenAI-compat fallback" + ); if all_models.is_empty() { return Err(anyhow::anyhow!( - "No chat models found in Fireworks account — the API returned empty results" + "No chat models found in Fireworks account" )); } @@ -688,15 +834,18 @@ fn discover_known_fireworks_models() -> Result> { } /// Check if a Fireworks model ID is a chat/instruct/text model suitable for -/// agent use. Excludes embedding, image-generation, audio, and other -/// non-chat model families. +/// agent use. Excludes embedding, reranker, image-generation, audio, and +/// other non-chat model families. /// -/// The `/inference/v1/models` endpoint already filters to models available for -/// inference, so we only need to exclude clearly non-chat categories. +/// When using the native API, the `supportsServerless` and `contextLength` +/// filters already narrow down to inference-ready models. This filter +/// further excludes names that are clearly not text-generation models. fn is_fireworks_chat_model(model_id: &str) -> bool { // Exclude known non-chat model families if model_id.contains("embedding") || model_id.contains("-embed-") + || model_id.contains("reranker") + || model_id.contains("-rerank-") || model_id.contains("whisper") || model_id.contains("tts") || model_id.contains("dall-e") @@ -709,10 +858,7 @@ fn is_fireworks_chat_model(model_id: &str) -> bool { return false; } - // Accept all models under accounts/fireworks/models/ — these are - // Fireworks-hosted serverless models available for text generation. - // The /inference/v1/models endpoint already pre-filters to inference-ready - // models, so anything it returns with this prefix is a valid chat model. + // Accept all models under accounts/fireworks/models/ model_id.starts_with("accounts/fireworks/models/") } @@ -894,24 +1040,20 @@ mod tests { assert!(is_fireworks_chat_model( "accounts/fireworks/models/glm-5p1" )); - assert!(is_fireworks_chat_model( - "accounts/fireworks/models/gemma-4-31b-it-nvfp4" - )); - assert!(is_fireworks_chat_model( - "accounts/fireworks/models/llama-v3p3-70b-instruct" + // Non-chat models are excluded + assert!(!is_fireworks_chat_model( + "accounts/fireworks/models/qwen3-embedding-8b" )); - // Non-chat models are excluded even with the prefix assert!(!is_fireworks_chat_model( - "accounts/fireworks/models/text-embedding-3" + "accounts/fireworks/models/qwen3-reranker-8b" )); assert!(!is_fireworks_chat_model( - "accounts/fireworks/models/flux-1-schnell" + "accounts/fireworks/models/flux-1-schnell-fp8" )); assert!(!is_fireworks_chat_model( "accounts/fireworks/models/whisper-large" )); // Models without the Fireworks prefix are not accepted - // (the /inference/v1/models endpoint returns Fireworks-prefixed IDs) assert!(!is_fireworks_chat_model("some-random-model")); assert!(!is_fireworks_chat_model("embedding-model")); } From 2e8add18346237ae271eec0331f3d15a5e045305 Mon Sep 17 00:00:00 2001 From: AgentFlow Date: Mon, 22 Jun 2026 13:10:16 +0100 Subject: [PATCH 4/6] fix: show loading screen while discovering models in TUI setup The TUI appeared to freeze after entering the API key because discover_models() was called before any UI was drawn. Now shows a 'Discovering available models...' message with the provider name while the HTTP request is in flight. --- crates/agentflow-tui/src/setup/step_agents.rs | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/agentflow-tui/src/setup/step_agents.rs b/crates/agentflow-tui/src/setup/step_agents.rs index e84bbf7..0e0d310 100644 --- a/crates/agentflow-tui/src/setup/step_agents.rs +++ b/crates/agentflow-tui/src/setup/step_agents.rs @@ -59,11 +59,53 @@ impl AgentsStep { .join("registry.json") }; + // Show a loading screen while discovering models + let provider_name = config.selected_provider.as_deref().unwrap_or("unknown"); + terminal.draw(|f| { + let area = f.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(2) + .constraints([ + Constraint::Length(4), + Constraint::Length(2), + Constraint::Min(1), + ]) + .split(area); + + let title_block = ratatui::widgets::Block::default() + .borders(ratatui::widgets::Borders::BOTTOM) + .border_style(Style::default().fg(theme.border())); + let inner_title = title_block.inner(chunks[0]); + title_block.render(chunks[0], f.buffer_mut()); + + let title = Line::styled( + "◇ CONFIGURE AGENTS", + Style::default().fg(theme.accent()).add_modifier(Modifier::BOLD), + ); + let title_text = ratatui::widgets::Paragraph::new(vec![title]); + title_text.render(inner_title, f.buffer_mut()); + + let loading = Line::styled( + format!(" ⏳ Discovering available models from {}...", provider_name), + Style::default().fg(theme.fg()), + ); + let hint = Line::styled( + " This may take a few seconds on first run.", + Style::default().fg(theme.muted()), + ); + let loading_text = ratatui::widgets::Paragraph::new(vec![loading, hint]); + loading_text.render(chunks[1], f.buffer_mut()); + })?; + // Discover models dynamically from the selected provider + tracing::info!(provider = %provider_name, "Starting model discovery"); let discovered_models = match discover_models(config).await { - Ok(models) => models, + Ok(models) => { + tracing::info!(count = models.len(), provider = %provider_name, "Model discovery succeeded"); + models + } Err(e) => { - // Fall back to default models if discovery fails tracing::warn!("Failed to discover models: {}, using defaults", e); vec![ModelInfo { slug: default_model_for_provider( From 59d2dd3330c7c0c680066518ca8f440c0684bf50 Mon Sep 17 00:00:00 2001 From: AgentFlow Date: Mon, 22 Jun 2026 13:23:02 +0100 Subject: [PATCH 5/6] fix: prevent infinite pagination loop in Fireworks model discovery - Add max page limit (5 pages) and 60s overall timeout for Fireworks native API - Add connect_timeout (10s) to all HTTP clients - Filter empty nextPageToken strings that would cause infinite loops - Return partial results on timeout/API error instead of failing entirely - Add max pages to Anthropic and OpenAI-compat pagination too - Fix indentation issue in discover_openai_models (removed stray loop) --- .../src/setup/model_discovery.rs | 93 +++++++++++++++---- 1 file changed, 75 insertions(+), 18 deletions(-) diff --git a/crates/agentflow-tui/src/setup/model_discovery.rs b/crates/agentflow-tui/src/setup/model_discovery.rs index 4a2be34..3fe5d0f 100644 --- a/crates/agentflow-tui/src/setup/model_discovery.rs +++ b/crates/agentflow-tui/src/setup/model_discovery.rs @@ -236,12 +236,18 @@ fn discover_codex_models() -> Result> { async fn discover_anthropic_models(api_key: &str) -> Result> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) .build()?; let mut all_models: Vec = Vec::new(); let mut after_id: Option = None; + let max_pages = 10; + let mut page_count = 0; loop { + if page_count >= max_pages { + break; + } let mut url = "https://api.anthropic.com/v1/models?limit=1000".to_string(); if let Some(ref id) = after_id { url.push_str(&format!("&after_id={}", id)); @@ -310,9 +316,15 @@ async fn discover_anthropic_models(api_key: &str) -> Result> { break; } + page_count += 1; + // Use the last model ID as the pagination cursor if let Some(last) = model_array.last() { - after_id = last.get("id").and_then(|v| v.as_str()).map(String::from); + let next = last.get("id").and_then(|v| v.as_str()).map(String::from); + if next.as_ref().map_or(true, |t| t.is_empty()) { + break; + } + after_id = next; } else { break; } @@ -425,12 +437,11 @@ fn map_claude_model_alias(alias: &str) -> &str { async fn discover_openai_models(api_key: &str) -> Result> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) .build()?; let mut all_models: Vec = Vec::new(); - // OpenAI returns all models in a single response (no cursor-based pagination), - // but supports a `limit` query param for fine-grained control. let url = "https://api.openai.com/v1/models?limit=500"; let response = match client @@ -528,24 +539,39 @@ fn is_openai_chat_model(model_id: &str) -> bool { /// Fetch available models from Fireworks API using the native /// `/v1/accounts/fireworks/models` endpoint (the OpenAI-compatible /// `/inference/v1/models` endpoint has been returning 500 errors). -/// Uses 30 s timeout, pageSize=200 pagination, and filters for +/// Uses 30 s per-request timeout, pageSize=200 pagination, and filters for /// serverless chat/text models with contextLength > 0. +/// Hard limit of 5 pages (~1000 models) and 60 s total wall-clock time. async fn discover_fireworks_models(api_key: &str) -> Result> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) .build()?; let mut all_models: Vec = Vec::new(); let mut page_token: Option = None; + let max_pages = 5; + let mut page_count = 0; + let start = std::time::Instant::now(); + let max_duration = std::time::Duration::from_secs(60); loop { + if page_count >= max_pages { + tracing::info!("Reached max page limit ({}) for Fireworks models", max_pages); + break; + } + if start.elapsed() > max_duration { + tracing::info!("Reached max duration ({:?}) for Fireworks models", max_duration); + break; + } + let mut url = "https://api.fireworks.ai/v1/accounts/fireworks/models?pageSize=200".to_string(); if let Some(ref token) = page_token { url.push_str(&format!("&pageToken={}", token)); } - tracing::info!(url = %url, "Fetching Fireworks models page"); + tracing::info!(url = %url, page = page_count + 1, "Fetching Fireworks models page"); let response = match client .get(&url) @@ -555,6 +581,17 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { { Ok(r) => r, Err(e) => { + if e.is_timeout() { + tracing::warn!("Fireworks API request timed out after 30s"); + // If we already have some models from previous pages, return them + if !all_models.is_empty() { + tracing::info!(count = all_models.len(), "Returning partial results after timeout"); + return Ok(all_models); + } + return Err(anyhow::anyhow!( + "Fireworks API request timed out after 30s. Check your network connection." + )); + } tracing::warn!(error = %e, "Fireworks native models request failed"); return Err(anyhow::anyhow!( "Failed to reach Fireworks API: {}. Check your network connection and API key.", @@ -566,16 +603,25 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { let status = response.status(); if !status.is_success() { let body = response.text().await.unwrap_or_default(); - tracing::warn!(status = %status, body = %body, "Fireworks native API error listing models"); - // Fall back to the OpenAI-compatible endpoint as a backup - tracing::info!("Native API failed, trying OpenAI-compatible endpoint as fallback"); + tracing::warn!(status = %status, body = %body, "Fireworks native API error"); + // If we already have models from previous pages, return partial results + if !all_models.is_empty() { + tracing::info!(count = all_models.len(), "Returning partial results after API error"); + return Ok(all_models); + } + // Otherwise try the OpenAI-compat fallback + tracing::info!("Native API failed, trying OpenAI-compatible fallback"); return discover_fireworks_models_openai_compat(api_key, &client).await; } let json: serde_json::Value = match response.json().await { Ok(v) => v, Err(e) => { - tracing::warn!(error = %e, "Failed to parse Fireworks native models response as JSON"); + if !all_models.is_empty() { + tracing::info!(count = all_models.len(), "Returning partial results after JSON parse failure"); + return Ok(all_models); + } + tracing::warn!(error = %e, "Failed to parse Fireworks native models response"); return Err(anyhow::anyhow!( "Failed to parse Fireworks models response: {}", e @@ -591,7 +637,8 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { tracing::info!( raw_count = model_array.len(), - "Fireworks native API returned models in this page" + page = page_count + 1, + "Fireworks native API returned models" ); for m in &model_array { @@ -601,7 +648,6 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { .unwrap_or("unknown") .to_string(); - // Only include serverless models available for inference let supports_serverless = m .get("supportsServerless") .and_then(|v| v.as_bool()) @@ -610,7 +656,6 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { continue; } - // Must have a positive context length (excludes image gen, etc.) let ctx_len = m .get("contextLength") .and_then(|v| v.as_u64()) @@ -619,7 +664,6 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { continue; } - // Exclude known non-chat families if !is_fireworks_chat_model(&name) { continue; } @@ -629,7 +673,6 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { .and_then(|v| v.as_str()) .map(String::from); - // Build a description with context length and capabilities let supports_tools = m .get("supportsTools") .and_then(|v| v.as_bool()) @@ -655,11 +698,14 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { }); } - // Check for next page + page_count += 1; + + // Check for next page — break on missing, null, or empty token let next_token = json .get("nextPageToken") .and_then(|v| v.as_str()) - .map(String::from); + .map(String::from) + .filter(|t| !t.is_empty()); if next_token.is_none() || model_array.is_empty() { break; @@ -671,7 +717,7 @@ async fn discover_fireworks_models(api_key: &str) -> Result> { if all_models.is_empty() { return Err(anyhow::anyhow!( - "No chat models found in Fireworks account — the API returned empty results" + "No chat models found in Fireworks account" )); } @@ -687,8 +733,13 @@ async fn discover_fireworks_models_openai_compat( ) -> Result> { let mut all_models: Vec = Vec::new(); let mut page_token: Option = None; + let max_pages = 5; + let mut page_count = 0; loop { + if page_count >= max_pages { + break; + } let mut url = "https://api.fireworks.ai/inference/v1/models".to_string(); if let Some(ref token) = page_token { url.push_str(&format!("?after={}", token)); @@ -772,6 +823,8 @@ async fn discover_fireworks_models_openai_compat( }); } + page_count += 1; + let has_more = json .get("has_more") .and_then(|v| v.as_bool()) @@ -782,7 +835,11 @@ async fn discover_fireworks_models_openai_compat( } if let Some(last) = model_array.last() { - page_token = last.get("id").and_then(|v| v.as_str()).map(String::from); + let next = last.get("id").and_then(|v| v.as_str()).map(String::from); + if next.as_ref().map_or(true, |t| t.is_empty()) { + break; + } + page_token = next; } else { break; } From de9c5aebec3aeaea924945c80753fefc33aeb33e Mon Sep 17 00:00:00 2001 From: AgentFlow Date: Mon, 22 Jun 2026 17:12:50 +0100 Subject: [PATCH 6/6] rustfmt --- crates/agent-client/src/runner.rs | 189 ++++++++++++++++- crates/agent-forge/src/lib.rs | 89 +++++++- crates/pair-harness/src/types.rs | 44 +++- crates/pair-harness/src/worktree.rs | 304 +++++++++++++++++++++++++++- orchestration/agent/registry.json | 46 +++-- 5 files changed, 643 insertions(+), 29 deletions(-) diff --git a/crates/agent-client/src/runner.rs b/crates/agent-client/src/runner.rs index 0ab79e7..bab6b3e 100644 --- a/crates/agent-client/src/runner.rs +++ b/crates/agent-client/src/runner.rs @@ -113,13 +113,15 @@ impl AgentRunner { let result = match self.mcp.call_tool(&name, args.clone()).await { Ok(r) => { let text = r.as_text(); + let truncated = truncate_tool_result(&text); info!( agent = persona.id, tool = name, - result = text, + original_len = text.len(), + truncated_len = truncated.len(), "Tool execution successful" ); - text + truncated } Err(e) => { warn!(agent = persona.id, tool = name, err = %e, "Tool call failed"); @@ -158,6 +160,189 @@ impl AgentRunner { // ── Helpers ─────────────────────────────────────────────────────────────── +/// Maximum characters for a tool result before truncation. +/// GitHub API responses for issues/PRs can be extremely large (each issue +/// contains ~2KB of user metadata, URLs, reactions, etc.). When the LLM +/// receives these in full, it often runs out of context budget and produces +/// unstructured reasoning instead of the required JSON decision. This limit +/// ensures the context stays manageable. +const MAX_TOOL_RESULT_CHARS: usize = 16_000; + +/// Truncates tool result text that exceeds MAX_TOOL_RESULT_CHARS. +/// +/// For JSON arrays (like GitHub issue lists), this summarizes each item +/// to only the essential fields. For non-JSON or small responses, returns +/// the text unchanged. +fn truncate_tool_result(text: &str) -> String { + if text.len() <= MAX_TOOL_RESULT_CHARS { + return text.to_string(); + } + + // Try to parse as JSON array and slim down each item + if let Ok(arr) = serde_json::from_str::>(text) { + let slimmed: Vec = arr + .into_iter() + .map(|item| slim_issue_object(&item)) + .collect(); + + if let Ok(slimmed_json) = serde_json::to_string_pretty(&slimmed) { + if slimmed_json.len() <= MAX_TOOL_RESULT_CHARS { + // Add a note that the response was summarized + return format!( + "[Note: This response was truncated from {} bytes to save context. Key fields preserved.]\n\n{}", + text.len(), + slimmed_json + ); + } + // If even slimmed JSON is too large, truncate to just issue numbers and titles + let minimal: Vec = slimmed + .into_iter() + .map(|item| { + let number = item.get("number").cloned().unwrap_or(serde_json::Value::Null); + let title = item.get("title").cloned().unwrap_or(serde_json::Value::Null); + let status = item.get("status").cloned().unwrap_or(serde_json::Value::Null); + serde_json::json!({ + "number": number, + "title": title, + "status": status, + }) + }) + .collect(); + + return format!( + "[Note: This response was heavily truncated from {} bytes. Only number, title, and status preserved.]\n\n{}", + text.len(), + serde_json::to_string_pretty(&minimal).unwrap_or_else(|_| format!("{:?}", minimal)) + ); + } + } + + // For non-JSON responses, just truncate with a note. + // Use char-boundary-safe truncation to avoid panicking on multi-byte UTF-8. + let truncated: String = text.chars().take(MAX_TOOL_RESULT_CHARS).collect(); + format!( + "[Note: Response truncated from {} bytes to {} bytes.]\n\n{}", + text.len(), + truncated.len(), + truncated + ) +} + +/// Reduce a GitHub issue/PR JSON object to its essential fields, +/// stripping verbose metadata like user URLs, reactions, timestamps, etc. +fn slim_issue_object(item: &serde_json::Value) -> serde_json::Value { + let obj = match item.as_object() { + Some(o) => o, + None => return item.clone(), + }; + + // Essential fields to keep for GitHub issues + let keep_keys = [ + "number", + "title", + "state", + "body", + "html_url", + "labels", + "assignees", + "milestone", + "status", + "ticket_id", + "worker_id", + "priority", + "branch", + "attempts", + "outcome", + "action", + "notes", + ]; + + let mut slim = serde_json::Map::new(); + for key in &keep_keys { + if let Some(val) = obj.get(*key) { + // Further slim down nested objects + let slimmed_val = match key { + &"labels" => slim_labels(val), + &"assignees" => slim_assignees(val), + &"milestone" => slim_milestone(val), + &"body" => { + // Truncate body text to reasonable length. + // Use chars() to avoid panicking on multi-byte UTF-8. + if let Some(s) = val.as_str() { + if s.len() > 2000 { + let truncated_body: String = s.chars().take(2000).collect(); + serde_json::Value::String(format!( + "{}\n\n[...truncated from {} chars]", + truncated_body, + s.chars().count() + )) + } else { + val.clone() + } + } else { + val.clone() + } + } + _ => val.clone(), + }; + slim.insert(key.to_string(), slimmed_val); + } + } + + serde_json::Value::Object(slim) +} + +fn slim_labels(val: &serde_json::Value) -> serde_json::Value { + let arr = match val.as_array() { + Some(a) => a, + None => return val.clone(), + }; + + let slimmed: Vec = arr + .iter() + .filter_map(|label| { + let obj = label.as_object()?; + Some(serde_json::json!({ + "name": obj.get("name"), + "color": obj.get("color"), + })) + }) + .collect(); + + serde_json::Value::Array(slimmed) +} + +fn slim_assignees(val: &serde_json::Value) -> serde_json::Value { + let arr = match val.as_array() { + Some(a) => a, + None => return val.clone(), + }; + + let slimmed: Vec = arr + .iter() + .filter_map(|user| { + let obj = user.as_object()?; + Some(serde_json::json!({ + "login": obj.get("login"), + })) + }) + .collect(); + + serde_json::Value::Array(slimmed) +} + +fn slim_milestone(val: &serde_json::Value) -> serde_json::Value { + let obj = match val.as_object() { + Some(o) => o, + None => return val.clone(), + }; + + serde_json::json!({ + "title": obj.get("title"), + "number": obj.get("number"), + }) +} + /// Extracts `{"action": ..., "notes": ...}` from the agent's final text. /// The LLM may include reasoning before the JSON object, so we scan for it. fn extract_decision(text: &str) -> Result { diff --git a/crates/agent-forge/src/lib.rs b/crates/agent-forge/src/lib.rs index d6ff022..e49c43f 100644 --- a/crates/agent-forge/src/lib.rs +++ b/crates/agent-forge/src/lib.rs @@ -13,12 +13,46 @@ use pair_harness::{ }; use pocketflow_core::{Action, BatchNode, SharedStore}; use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::path::PathBuf; use tracing::{info, warn}; +/// Custom deserializer that converts an empty string to `None` for `Option`. +/// LLMs frequently write `"pr_number": ""` instead of `"pr_number": null` or +/// `"pr_number": 42`, which causes serde to fail. This deserializer gracefully +/// handles that case, and also converts numeric strings like `"42"`. +fn deserialize_opt_u64_from_maybe_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de; + + #[derive(Deserialize)] + #[serde(untagged)] + enum MaybeU64 { + Number(u64), + Null, + String(String), + } + + match MaybeU64::deserialize(deserializer) { + Ok(MaybeU64::Number(n)) => Ok(Some(n)), + Ok(MaybeU64::Null) => Ok(None), + Ok(MaybeU64::String(s)) => { + if s.is_empty() { + Ok(None) + } else { + s.parse::() + .map(Some) + .map_err(|_| de::Error::custom(format!("cannot parse \"{}\" as u64", s))) + } + } + Err(e) => Err(e), + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ForgeStatus { /// Outcome status - can be "outcome" or "status" in STATUS.json @@ -35,7 +69,11 @@ pub struct ForgeStatus { #[serde(alias = "pr")] pub pr_url: Option, /// PR number if a PR was opened - pub pr_number: Option, + #[serde( + deserialize_with = "deserialize_opt_u64_from_maybe_string", + default + )] + pub pr_number: Option, /// Notes about the work done pub notes: Option, /// Summary of changes (optional) @@ -863,6 +901,12 @@ impl ForgePairNode { // Check for commits beyond the default branch. // First try the local branch name, then fall back to the remote-tracking // ref (origin/{default_branch}) which is more reliable after fetches. + // Before checking, fetch the default branch to ensure the remote ref is up-to-date. + let _ = StdCommand::new("git") + .args(["fetch", "origin", default_branch]) + .current_dir(&worktree_path) + .output(); + let has_commits = Self::has_commits_beyond_branch(&worktree_path, default_branch); if !has_commits { @@ -1401,6 +1445,47 @@ impl ForgePairNode { } } + // If the default branch doesn't exist at all (e.g., new repo with only + // feature branches), check if HEAD has any commits. Any commits at all + // means the branch has work worth pushing, since there's no base to + // compare against. This handles the case where a repo was created with + // only a forge-1/T-CI-001 branch and no main/master. + let branch_exists_locally = local_output + .as_ref() + .map(|o| o.status.success()) + .unwrap_or(false); + let branch_exists_remotely = origin_output + .as_ref() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !branch_exists_locally && !branch_exists_remotely { + // The default branch ref doesn't exist at all. + // If HEAD has any commits, they are inherently "beyond" a non-existent base. + let head_commits = StdCommand::new("git") + .args(["rev-list", "--count", "HEAD"]) + .current_dir(worktree_path) + .output(); + + if let Ok(o) = head_commits { + if o.status.success() { + if let Ok(count_str) = String::from_utf8(o.stdout) { + if let Ok(count) = count_str.trim().parse::() { + if count > 0 { + tracing::info!( + default_branch = %default_branch, + count, + "Default branch doesn't exist; HEAD has {} commits, treating as pushable", + count + ); + return true; + } + } + } + } + } + } + false } diff --git a/crates/pair-harness/src/types.rs b/crates/pair-harness/src/types.rs index 5c32c24..619f4ea 100644 --- a/crates/pair-harness/src/types.rs +++ b/crates/pair-harness/src/types.rs @@ -1,12 +1,48 @@ // crates/pair-harness/src/types.rs //! Core types for the pair-harness system. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use std::path::PathBuf; // Re-export CliBackend from the config crate — single source of truth. pub use config::registry::CliBackend; +/// Custom deserializer that converts an empty string to `None` for `Option`. +/// LLMs frequently write `"pr_number": ""` instead of `"pr_number": null` or +/// `"pr_number": 42`, which causes serde to fail with "invalid type: string, +/// expected u64". This deserializer gracefully handles that case. +fn deserialize_opt_u64_from_maybe_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de; + + // Use a helper enum that can accept a number, null, or a string + #[derive(Deserialize)] + #[serde(untagged)] + enum MaybeU64 { + Number(u64), + Null, + String(String), + } + + match MaybeU64::deserialize(deserializer) { + Ok(MaybeU64::Number(n)) => Ok(Some(n)), + Ok(MaybeU64::Null) => Ok(None), + Ok(MaybeU64::String(s)) => { + if s.is_empty() { + Ok(None) + } else { + // Try parsing the string as a number + s.parse::() + .map(Some) + .map_err(|_| de::Error::custom(format!("cannot parse \"{}\" as u64", s))) + } + } + Err(e) => Err(e), + } +} + /// Filesystem events detected by the watcher. /// These drive the event-driven harness state machine. #[derive(Debug, Clone, PartialEq, Eq)] @@ -308,7 +344,11 @@ pub struct StatusJson { #[serde(skip_serializing_if = "Option::is_none")] pub pr_url: Option, /// PR number (if PR_OPENED) - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_opt_u64_from_maybe_string", + default + )] pub pr_number: Option, /// Branch name (optional - may not be present in all STATUS.json formats) #[serde(default)] diff --git a/crates/pair-harness/src/worktree.rs b/crates/pair-harness/src/worktree.rs index 8762503..f5d78be 100644 --- a/crates/pair-harness/src/worktree.rs +++ b/crates/pair-harness/src/worktree.rs @@ -77,10 +77,12 @@ impl WorktreeManager { /// Detect the repository's default branch from origin/HEAD. /// - /// Uses `git symbolic-ref refs/remotes/origin/HEAD` to read the - /// remote HEAD symref, which GitHub sets to point at the default - /// branch (e.g., refs/remotes/origin/master). Falls back to - /// trying "main" then "master" via remote ref existence checks. + /// Uses multiple detection methods in order of reliability: + /// 1. Read origin/HEAD symref (most reliable when set) + /// 2. Query remote via `git ls-remote --symref` (works even without local refs) + /// 3. Check local remote-tracking refs + /// 4. Try git rev-parse for each candidate + /// 5. Final fallback to "main" pub fn detect_default_branch(project_root: &Path) -> String { // Method 1: Read origin/HEAD symref (most reliable) let output = Command::new("git") @@ -100,7 +102,33 @@ impl WorktreeManager { } } - // Method 2: Check which remote branch ref exists + // Method 2: Query remote HEAD via ls-remote --symref (works without local refs) + // Only accept the result if it's a standard default branch name (main/master). + // Non-standard branches like forge-1/T-CI-001 should not be treated as + // the default branch — the system should create a proper main/master instead. + let ls_remote_output = Command::new("git") + .args(["ls-remote", "--symref", "origin", "HEAD"]) + .current_dir(project_root) + .output(); + + if let Ok(o) = ls_remote_output { + let stdout = String::from_utf8_lossy(&o.stdout); + // Output format: "ref: refs/heads/{branch}\tHEAD" + for line in stdout.lines() { + if let Some(rest) = line.strip_prefix("ref: refs/heads/") { + if let Some(branch) = rest.split('\t').next() { + if !branch.is_empty() + && (branch == "main" || branch == "master") + { + info!(branch = %branch, "Detected default branch via ls-remote --symref"); + return branch.to_string(); + } + } + } + } + } + + // Method 3: Check which remote branch ref exists for candidate in ["main", "master"] { let ref_path = format!("refs/remotes/origin/{candidate}"); let git_dir = project_root.join(".git"); @@ -116,7 +144,7 @@ impl WorktreeManager { } } - // Method 3: Try git rev-parse for each candidate + // Method 4: Try git rev-parse for each candidate for candidate in ["main", "master"] { let output = Command::new("git") .args(["rev-parse", "--verify", &format!("origin/{candidate}")]) @@ -129,11 +157,263 @@ impl WorktreeManager { } } + // Method 5: Use ls-remote to check which standard branches exist on the remote + // This works even without local refs (e.g., fresh clone with only feature branches) + for candidate in ["main", "master"] { + if Self::remote_branch_exists(project_root, candidate) { + info!(branch = %candidate, "Detected default branch via ls-remote"); + return candidate.to_string(); + } + } + // Final fallback warn!("Could not detect default branch, falling back to 'main'"); "main".to_string() } + /// Check whether a remote branch exists on origin. + fn remote_branch_exists(project_root: &Path, branch: &str) -> bool { + let output = Command::new("git") + .args(["ls-remote", "--heads", "origin", branch]) + .current_dir(project_root) + .output(); + + match output { + Ok(o) if o.status.success() => { + let stdout = String::from_utf8_lossy(&o.stdout); + // Non-empty output means the ref exists on the remote + !stdout.trim().is_empty() + } + _ => false, + } + } + + /// Ensure the default branch exists on the remote repository. + /// + /// New or empty repositories on GitHub may only have feature branches + /// (e.g., from prior worktree commits) with no `main` or `master` branch. + /// This creates an empty initial commit on the default branch and pushes + /// it so that worktree operations and push validation can succeed. + /// + /// Uses the safe `commit-tree` approach first (doesn't touch HEAD or index), + /// falling back to orphan checkout only if needed. + fn ensure_default_branch_on_remote(&self) -> Result<()> { + // First check if the default branch already exists on the remote + if Self::remote_branch_exists(&self.project_root, &self.default_branch) { + info!(branch = %self.default_branch, "Default branch already exists on remote"); + return Ok(()); + } + + warn!( + branch = %self.default_branch, + "Default branch does not exist on remote — creating it with an initial commit" + ); + + // Prefer the safe commit-tree approach that doesn't touch HEAD or index. + // Only fall back to orphan checkout if commit-tree fails. + if let Err(e) = self.ensure_default_branch_via_commit_tree() { + warn!(error = %e, "commit-tree approach failed, trying orphan checkout fallback"); + self.ensure_default_branch_via_orphan_checkout() + } else { + Ok(()) + } + } + + /// Safe approach: create default branch using git commit-tree. + /// This doesn't touch the working tree, HEAD, or index at all. + fn ensure_default_branch_via_commit_tree(&self) -> Result<()> { + // Create an empty tree using the well-known empty tree SHA-1 hash + let empty_tree = "4b825dc642cb6eb9a060e54bf899d15363d7b6ed"; + + // Create a commit from the empty tree + let commit_output = Command::new("git") + .args([ + "commit-tree", + empty_tree, + "-m", + "Initial commit", + ]) + .env("GIT_AUTHOR_NAME", "AgentFlow") + .env("GIT_AUTHOR_EMAIL", "agentflow@github.com") + .env("GIT_COMMITTER_NAME", "AgentFlow") + .env("GIT_COMMITTER_EMAIL", "agentflow@github.com") + .current_dir(&self.project_root) + .output(); + + let commit_hash = match commit_output { + Ok(o) if o.status.success() => { + let hash = String::from_utf8_lossy(&o.stdout).trim().to_string(); + info!(hash = %hash, "Created empty root commit for default branch"); + hash + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + return Err(anyhow!("Failed to create root commit: {}", stderr)); + } + Err(e) => { + return Err(anyhow!("Failed to create root commit: {}", e)); + } + }; + + // Push the commit as the default branch to origin + let push_output = Command::new("git") + .args([ + "push", + "origin", + &format!("{}:refs/heads/{}", commit_hash, self.default_branch), + ]) + .current_dir(&self.project_root) + .output(); + + match push_output { + Ok(o) if o.status.success() => { + info!(branch = %self.default_branch, "Default branch created on remote via commit-tree"); + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + if stderr.contains("already exists") || stderr.contains("up-to-date") { + info!(branch = %self.default_branch, "Default branch already exists on remote (concurrent creation)"); + } else { + return Err(anyhow!("Failed to push default branch: {}", stderr)); + } + } + Err(e) => { + return Err(anyhow!("Failed to push default branch: {}", e)); + } + } + + // Fetch so the remote-tracking ref is available + let _ = self.run_git_in_main(&["fetch", "origin", &self.default_branch]); + + Ok(()) + } + + /// Fallback: create default branch by checking out an orphan branch. + /// This modifies HEAD and the index in the project root, so it should only + /// be used when the safer commit-tree approach fails. + fn ensure_default_branch_via_orphan_checkout(&self) -> Result<()> { + // Save current HEAD so we can restore it after creating the branch + let saved_head = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&self.project_root) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else { + None + } + }); + + // Create an orphan branch with an initial commit + let orphan_output = Command::new("git") + .args(["checkout", "--orphan", &self.default_branch]) + .current_dir(&self.project_root) + .output(); + + let orphan_ok = match orphan_output { + Ok(o) => o.status.success(), + Err(e) => { + warn!(error = %e, "Failed to create orphan branch"); + return Err(anyhow!("Failed to create orphan branch: {}", e)); + } + }; + + if !orphan_ok { + return Err(anyhow!("Failed to checkout orphan branch {}", self.default_branch)); + } + + // Remove everything from index (we want an empty initial commit) + let _ = Command::new("git") + .args(["rm", "-rf", "--cached", "."]) + .current_dir(&self.project_root) + .output(); + + // Create the initial commit + let commit_output = Command::new("git") + .args(["commit", "--allow-empty", "-m", "Initial commit"]) + .current_dir(&self.project_root) + .env("GIT_AUTHOR_NAME", "AgentFlow") + .env("GIT_AUTHOR_EMAIL", "agentflow@github.com") + .env("GIT_COMMITTER_NAME", "AgentFlow") + .env("GIT_COMMITTER_EMAIL", "agentflow@github.com") + .output(); + + let commit_ok = match commit_output { + Ok(o) => o.status.success(), + Err(e) => { + warn!(error = %e, "Failed to create initial commit"); + false + } + }; + + if !commit_ok { + // Restore original HEAD before returning error + Self::restore_head(&self.project_root, saved_head); + return Err(anyhow!("Failed to create initial commit on orphan branch")); + } + + // Push the default branch to origin + let push_output = Command::new("git") + .args(["push", "origin", &self.default_branch]) + .current_dir(&self.project_root) + .output(); + + match push_output { + Ok(o) if o.status.success() => { + info!(branch = %self.default_branch, "Default branch created and pushed to remote"); + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + // If push fails because the branch already exists (race), that's fine + if stderr.contains("already exists") || stderr.contains("up-to-date") { + info!(branch = %self.default_branch, "Default branch already exists on remote (concurrent creation)"); + } else { + // Restore HEAD before returning error + Self::restore_head(&self.project_root, saved_head); + return Err(anyhow!("Failed to push default branch: {}", stderr)); + } + } + Err(e) => { + // Restore HEAD before returning error + Self::restore_head(&self.project_root, saved_head); + return Err(anyhow!("Failed to push default branch: {}", e)); + } + } + + // Restore original HEAD + Self::restore_head(&self.project_root, saved_head); + + // Fetch so the remote-tracking ref is available + let _ = self.run_git_in_main(&["fetch", "origin", &self.default_branch]); + + // Try to clean up the local orphan branch ref + // (this may fail if we're on it, which is fine) + let _ = Command::new("git") + .args(["branch", "-d", &self.default_branch]) + .current_dir(&self.project_root) + .output(); + + Ok(()) + } + + fn restore_head(project_root: &Path, saved_head: Option) { + if let Some(ref head) = saved_head { + // Checkout the original branch/commit + let _ = Command::new("git") + .args(["checkout", head]) + .current_dir(project_root) + .output(); + } else { + // No saved HEAD, try to go back to whatever branch we were on + let _ = Command::new("git") + .args(["checkout", "-"]) + .current_dir(project_root) + .output(); + } + } + /// Returns the origin-qualified default branch ref (e.g., "origin/main" or "origin/master"). fn origin_default_branch(&self) -> String { format!("origin/{}", self.default_branch) @@ -294,6 +574,18 @@ impl WorktreeManager { // Ensure worktrees/ is in .gitignore so git doesn't track the worktree dirs self.ensure_worktrees_gitignored(); + // Ensure the default branch exists on the remote repository. + // New or empty repos may only have feature branches with no `main`/`master`, + // which causes fetch and push validation to fail. + if let Err(e) = self.ensure_default_branch_on_remote() { + warn!(error = %e, "Failed to ensure default branch exists on remote, continuing anyway"); + warnings.push(SetupWarning { + phase: "ensure_default_branch".to_string(), + error: format!("Could not verify/create default branch on remote: {}", e), + affected_files: vec![], + }); + } + if let Err(e) = self.run_git_in_main(&["fetch", "origin", &self.default_branch]) { warn!(error = %e, default_branch = %self.default_branch, "git fetch origin/{} failed, continuing", self.default_branch); warnings.push(SetupWarning { diff --git a/orchestration/agent/registry.json b/orchestration/agent/registry.json index 79ffc36..e403231 100644 --- a/orchestration/agent/registry.json +++ b/orchestration/agent/registry.json @@ -1,50 +1,62 @@ { - "default_cli": "claude", + "default_cli": "codex", + "allowed_domains": [ + "api.github.com", + "*.github.com", + "pypi.org", + "registry.npmjs.org", + "crates.io" + ], "team": [ { "id": "nexus", - "cli": "claude", + "cli": "codex", "active": true, "instances": 1, - "model_backend": "anthropic/claude-haiku-4-5-20251001", + "model_backend": "fireworks/accounts/fireworks/models/kimi-k2p6", "routing_key": "nexus-key", - "github_token_env": "AGENT_NEXUS_GITHUB_TOKEN" + "github_token_env": "AGENT_NEXUS_GITHUB_TOKEN", + "allowed_domains": null }, { "id": "forge", - "cli": "claude", + "cli": "codex", "active": true, "instances": 2, - "model_backend": "anthropic/claude-haiku-4-5-20251001", + "model_backend": "fireworks/accounts/fireworks/models/kimi-k2p6", "routing_key": "forge-key", - "github_token_env": "AGENT_FORGE_GITHUB_TOKEN" + "github_token_env": "AGENT_FORGE_GITHUB_TOKEN", + "allowed_domains": null }, { "id": "sentinel", - "cli": "claude", + "cli": "codex", "active": true, "instances": 1, - "model_backend": "anthropic/claude-haiku-4-5-20251001", + "model_backend": "fireworks/accounts/fireworks/models/kimi-k2p6", "routing_key": "sentinel-key", - "github_token_env": "AGENT_SENTINEL_GITHUB_TOKEN" + "github_token_env": "AGENT_SENTINEL_GITHUB_TOKEN", + "allowed_domains": null }, - { + { "id": "vessel", - "cli": "claude", + "cli": "codex", "active": true, "instances": 1, - "model_backend": "anthropic/claude-haiku-4-5-20251001", + "model_backend": "fireworks/accounts/fireworks/models/kimi-k2p6", "routing_key": "vessel-key", - "github_token_env": "AGENT_VESSEL_GITHUB_TOKEN" + "github_token_env": "AGENT_VESSEL_GITHUB_TOKEN", + "allowed_domains": null }, { "id": "lore", - "cli": "claude", + "cli": "codex", "active": false, "instances": 1, - "model_backend": "anthropic/claude-haiku-4-5-20251001", + "model_backend": "fireworks/accounts/fireworks/models/kimi-k2p6", "routing_key": "lore-key", - "github_token_env": "AGENT_LORE_GITHUB_TOKEN" + "github_token_env": "AGENT_LORE_GITHUB_TOKEN", + "allowed_domains": null } ] } \ No newline at end of file