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/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/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..3fe5d0f 100644 --- a/crates/agentflow-tui/src/setup/model_discovery.rs +++ b/crates/agentflow-tui/src/setup/model_discovery.rs @@ -15,35 +15,92 @@ 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"); + } + } + } + // Try CLI discovery as second fallback + if let Ok(models) = discover_claude_models_from_cli() { + if !models.is_empty() { + return Ok(models); } } - // Fallback to CLI discovery - discover_claude_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() { + 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" + ); + } + } else { + tracing::warn!( + "No Fireworks API key configured — skipping live discovery, using minimal list" + ); } - Err(anyhow::anyhow!("No Fireworks API key configured")) + discover_known_fireworks_models() } _ => discover_backend_models(&config.selected_cli_backend), } @@ -52,7 +109,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 +232,57 @@ 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)) + .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)); + } - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Anthropic API returned status {} when listing models", - response.status() - )); - } + 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 mut models = Vec::new(); + let json: serde_json::Value = response.json().await?; + let has_more = json + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); - if let Some(model_array) = json.get("data").and_then(|v| v.as_array()) { - for m in model_array { + 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 +305,38 @@ 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; + } + + page_count += 1; + + // Use the last model ID as the pagination cursor + if let Some(last) = model_array.last() { + 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; + } } - 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 +373,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 +433,95 @@ 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)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build()?; + + let mut all_models: Vec = Vec::new(); + + 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 +536,271 @@ 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 using the native +/// `/v1/accounts/fireworks/models` endpoint (the OpenAI-compatible +/// `/inference/v1/models` endpoint has been returning 500 errors). +/// 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::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)) + .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; + } - if !response.status().is_success() { + 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, page = page_count + 1, "Fetching Fireworks models page"); + + let response = match client + .get(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await + { + 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.", + e + )); + } + }; + + 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"); + // 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) => { + 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 + )); + } + }; + + let model_array = json + .get("models") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + tracing::info!( + raw_count = model_array.len(), + page = page_count + 1, + "Fireworks native API returned models" + ); + + for m in &model_array { + let name = m + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let supports_serverless = m + .get("supportsServerless") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !supports_serverless { + continue; + } + + let ctx_len = m + .get("contextLength") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + if ctx_len == 0 { + continue; + } + + if !is_fireworks_chat_model(&name) { + continue; + } + + let display_name = m + .get("displayName") + .and_then(|v| v.as_str()) + .map(String::from); + + 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, + }); + } + + 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) + .filter(|t| !t.is_empty()); + + 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!( - "Fireworks API returned status {} when listing models", - response.status() + "No chat models found in Fireworks account" )); } - let json: serde_json::Value = response.json().await?; - let mut models = Vec::new(); + Ok(all_models) +} - if let Some(model_array) = json.get("data").and_then(|v| v.as_array()) { - for m in model_array { +/// 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; + 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)); + } + + 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") .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 +816,151 @@ 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, }); } + + page_count += 1; + + let has_more = json + .get("has_more") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !has_more || model_array.is_empty() { + break; + } + + if let Some(last) = model_array.last() { + 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; + } } - 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 OpenAI-compat fallback" + ); + + if all_models.is_empty() { + return Err(anyhow::anyhow!( + "No chat models found in Fireworks account" + )); } - 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, reranker, image-generation, audio, and +/// other non-chat model families. +/// +/// 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 { - // 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("reranker") + || model_id.contains("-rerank-") + || 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/ + 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 +981,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 +1007,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 +1081,86 @@ 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" + )); + // Non-chat models are excluded + assert!(!is_fireworks_chat_model( + "accounts/fireworks/models/qwen3-embedding-8b" + )); + assert!(!is_fireworks_chat_model( + "accounts/fireworks/models/qwen3-reranker-8b" + )); + assert!(!is_fireworks_chat_model( + "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 + 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/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( 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"), 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