fix: resolve 5 bugs + close 1 resolved (#875, #872, #867, #824, #833, #766)

- #875: Install script uses robust sed parsing instead of fragile cut for version detection
- #872: Session endpoint returns full tool results (removed 2000-char truncation)
- #867: agent_send/agent_spawn get 600s timeout (was 120s), regular tools keep 120s
- #824: Doctor workspace skills count uses direct return value from load_workspace_skills
- #833: Model switching respects provider via new find_model_for_provider() lookup
- #766: Closed as resolved by combined heartbeat fixes (v0.5.3 + merged PRs)

All tests passing. Live tested with daemon.
This commit is contained in:
jaberjaber23
2026-03-27 22:42:24 +03:00
parent f98bc330d4
commit 9fef6d6c91
7 changed files with 261 additions and 14 deletions
+1 -3
View File
@@ -579,9 +579,7 @@ pub async fn get_agent_session(
msg.get_mut("tools").and_then(|v| v.as_array_mut())
{
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
let preview: String =
result.chars().take(2000).collect();
tool_obj["result"] = serde_json::Value::String(preview);
tool_obj["result"] = serde_json::Value::String(result.clone());
tool_obj["is_error"] =
serde_json::Value::Bool(*is_error);
}
+1 -3
View File
@@ -2642,9 +2642,7 @@ decay_rate = 0.05
// Check workspace skills if home dir available
if skills_dir.exists() {
match skill_reg.load_workspace_skills(&skills_dir) {
Ok(_) => {
let total = skill_reg.count();
let ws_count = total.saturating_sub(bundled_count);
Ok(ws_count) => {
if ws_count > 0 {
if !json {
ui::check_ok(&format!("Workspace skills loaded: {ws_count}"));
+10 -1
View File
@@ -2893,7 +2893,16 @@ impl OpenFangKernel {
.model_catalog
.read()
.ok()
.and_then(|catalog| catalog.find_model(model).cloned());
.and_then(|catalog| {
// When the caller specifies a provider, use provider-aware lookup
// so we resolve the model on the correct provider — not a builtin
// from a different provider that happens to share the same name (#833).
if let Some(ep) = explicit_provider {
catalog.find_model_for_provider(model, ep).cloned()
} else {
catalog.find_model(model).cloned()
}
});
let provider = if let Some(ep) = explicit_provider {
// User explicitly set the provider — use it as-is
Some(ep.to_string())
+33 -6
View File
@@ -44,6 +44,20 @@ const BASE_RETRY_DELAY_MS: u64 = 1000;
/// Raised from 60s to 120s for browser automation and long-running builds.
const TOOL_TIMEOUT_SECS: u64 = 120;
/// Timeout for inter-agent tool calls (seconds).
/// Agent delegation (agent_send, agent_spawn) can involve a full agent loop on the
/// target, so these need a significantly longer timeout than regular tools.
const AGENT_TOOL_TIMEOUT_SECS: u64 = 600;
/// Returns the appropriate timeout duration for a given tool name.
/// Inter-agent calls get a longer timeout since they may trigger full agent loops.
fn tool_timeout_for(tool_name: &str) -> Duration {
match tool_name {
"agent_send" | "agent_spawn" => Duration::from_secs(AGENT_TOOL_TIMEOUT_SECS),
_ => Duration::from_secs(TOOL_TIMEOUT_SECS),
}
}
/// Maximum consecutive MaxTokens continuations before returning partial response.
/// Raised from 3 to 5 to allow longer-form generation.
const MAX_CONTINUATIONS: u32 = 5;
@@ -738,8 +752,10 @@ pub async fn run_agent_loop(
let effective_exec_policy = manifest.exec_policy.as_ref();
// Timeout-wrapped execution
let timeout = tool_timeout_for(&tool_call.name);
let timeout_secs = timeout.as_secs();
let result = match tokio::time::timeout(
Duration::from_secs(TOOL_TIMEOUT_SECS),
timeout,
tool_runner::execute_tool(
&tool_call.id,
&tool_call.name,
@@ -768,12 +784,12 @@ pub async fn run_agent_loop(
{
Ok(result) => result,
Err(_) => {
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", TOOL_TIMEOUT_SECS);
warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs);
openfang_types::tool::ToolResult {
tool_use_id: tool_call.id.clone(),
content: format!(
"Tool '{}' timed out after {}s.",
tool_call.name, TOOL_TIMEOUT_SECS
tool_call.name, timeout_secs
),
is_error: true,
}
@@ -1881,8 +1897,10 @@ pub async fn run_agent_loop_streaming(
let effective_exec_policy = manifest.exec_policy.as_ref();
// Timeout-wrapped execution
let timeout = tool_timeout_for(&tool_call.name);
let timeout_secs = timeout.as_secs();
let result = match tokio::time::timeout(
Duration::from_secs(TOOL_TIMEOUT_SECS),
timeout,
tool_runner::execute_tool(
&tool_call.id,
&tool_call.name,
@@ -1911,12 +1929,12 @@ pub async fn run_agent_loop_streaming(
{
Ok(result) => result,
Err(_) => {
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", TOOL_TIMEOUT_SECS);
warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs);
openfang_types::tool::ToolResult {
tool_use_id: tool_call.id.clone(),
content: format!(
"Tool '{}' timed out after {}s.",
tool_call.name, TOOL_TIMEOUT_SECS
tool_call.name, timeout_secs
),
is_error: true,
}
@@ -2980,6 +2998,15 @@ mod tests {
#[test]
fn test_tool_timeout_constant() {
assert_eq!(TOOL_TIMEOUT_SECS, 120);
assert_eq!(AGENT_TOOL_TIMEOUT_SECS, 600);
}
#[test]
fn test_tool_timeout_for_agent_tools() {
assert_eq!(tool_timeout_for("agent_send"), Duration::from_secs(600));
assert_eq!(tool_timeout_for("agent_spawn"), Duration::from_secs(600));
assert_eq!(tool_timeout_for("file_read"), Duration::from_secs(120));
assert_eq!(tool_timeout_for("shell_exec"), Duration::from_secs(120));
}
#[test]
@@ -172,6 +172,72 @@ impl ModelCatalog {
None
}
/// Find a model by ID/alias, preferring entries from the given provider.
///
/// When `provider` is specified, this method first looks for a matching model
/// that belongs to that provider. If no provider-scoped match is found, it
/// falls back to the normal `find_model` resolution.
///
/// This prevents issue #833 where switching to model "kimi-2.5" with provider
/// "model_studio" would incorrectly resolve to moonshot's builtin kimi-2.5
/// because `find_model` does not consider provider affinity.
pub fn find_model_for_provider(
&self,
id_or_alias: &str,
provider: &str,
) -> Option<&ModelCatalogEntry> {
let lower = id_or_alias.to_lowercase();
// First pass: look for a match scoped to the requested provider.
// Priority: exact-case ID > case-insensitive ID > display-name.
let mut provider_ci: Option<&ModelCatalogEntry> = None;
for m in &self.models {
if m.provider != provider {
continue;
}
if m.id.to_lowercase() != lower {
continue;
}
if m.id == id_or_alias {
return Some(m); // Exact-case match on the right provider — best result
}
if provider_ci.is_none() {
provider_ci = Some(m);
}
}
if let Some(entry) = provider_ci {
return Some(entry);
}
// Display-name match scoped to provider
if let Some(entry) = self
.models
.iter()
.find(|m| m.provider == provider && m.display_name.to_lowercase() == lower)
{
return Some(entry);
}
// Alias resolution scoped to provider: resolve the alias, then check if
// the canonical model belongs to the requested provider.
if let Some(canonical) = self.aliases.get(&lower) {
if let Some(entry) = self
.models
.iter()
.find(|m| m.id == *canonical && m.provider == provider)
{
return Some(entry);
}
}
// No provider-scoped match — fall back to normal resolution so callers
// still get a result when the model genuinely doesn't exist on this provider
// (e.g. user typo, or a model name that only exists elsewhere).
self.find_model(id_or_alias)
}
/// Resolve an alias to a canonical model ID, or None if not an alias.
pub fn resolve_alias(&self, alias: &str) -> Option<&str> {
self.aliases.get(&alias.to_lowercase()).map(|s| s.as_str())
@@ -4365,4 +4431,96 @@ mod tests {
assert_eq!(lower.tier, ModelTier::Local);
assert_eq!(lower.provider, "ollama");
}
/// Regression test for #833: find_model_for_provider should prefer the entry
/// from the specified provider when multiple providers share the same model name.
///
/// Scenario: a custom provider "model_studio" has a model "kimi-k2.5", and the
/// builtin "moonshot" provider also has "kimi-k2.5". When the user switches to
/// "kimi-k2.5" with provider "model_studio", we must resolve to model_studio's
/// entry, not moonshot's builtin.
#[test]
fn test_find_model_for_provider_prefers_specified_provider_833() {
let mut catalog = ModelCatalog::new();
// Verify the builtin moonshot entry exists
let builtin = catalog.find_model("kimi-k2.5").unwrap();
assert_eq!(builtin.provider, "moonshot");
// Add a custom model with the same name on a different provider
let added = catalog.add_custom_model(ModelCatalogEntry {
id: "kimi-k2.5".into(),
display_name: "Kimi K2.5 (Model Studio)".into(),
provider: "model_studio".into(),
tier: ModelTier::Balanced,
context_window: 131_072,
max_output_tokens: 8_192,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
});
assert!(added, "custom model should be added (different provider)");
// Plain find_model returns the custom entry (Custom tier wins over builtin)
let plain = catalog.find_model("kimi-k2.5").unwrap();
assert_eq!(plain.tier, ModelTier::Custom);
// find_model_for_provider with "model_studio" must return model_studio's entry
let ms = catalog
.find_model_for_provider("kimi-k2.5", "model_studio")
.unwrap();
assert_eq!(ms.provider, "model_studio");
assert_eq!(ms.display_name, "Kimi K2.5 (Model Studio)");
// find_model_for_provider with "moonshot" must return moonshot's builtin
let moonshot = catalog
.find_model_for_provider("kimi-k2.5", "moonshot")
.unwrap();
assert_eq!(moonshot.provider, "moonshot");
assert_eq!(moonshot.display_name, "Kimi K2.5");
}
/// Verify find_model_for_provider falls back to normal resolution when the
/// model doesn't exist on the requested provider.
#[test]
fn test_find_model_for_provider_fallback() {
let catalog = ModelCatalog::new();
// "claude-sonnet-4-20250514" only exists on "anthropic"
let entry = catalog
.find_model_for_provider("claude-sonnet-4-20250514", "nonexistent_provider")
.unwrap();
assert_eq!(entry.provider, "anthropic");
}
/// Verify find_model_for_provider is case-insensitive for the model name.
#[test]
fn test_find_model_for_provider_case_insensitive() {
let mut catalog = ModelCatalog::new();
catalog.add_custom_model(ModelCatalogEntry {
id: "My-Custom-LLM".into(),
display_name: "My Custom LLM".into(),
provider: "custom_provider".into(),
tier: ModelTier::Balanced,
context_window: 32_768,
max_output_tokens: 4_096,
input_cost_per_m: 0.0,
output_cost_per_m: 0.0,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
});
// Case-insensitive lookup with the correct provider
let found = catalog
.find_model_for_provider("my-custom-llm", "custom_provider")
.unwrap();
assert_eq!(found.provider, "custom_provider");
assert_eq!(found.id, "My-Custom-LLM");
}
}
+57
View File
@@ -732,4 +732,61 @@ input_schema = { type = "object" }
"Global beta should remain unchanged"
);
}
/// #824: load_workspace_skills must return the count of workspace skills loaded,
/// even when a workspace skill overrides a global skill with the same HashMap key.
/// The old doctor code computed `total - bundled_count` which underreported when
/// an override didn't increase total_loaded.
#[test]
fn test_workspace_override_returns_correct_count() {
let global_dir = TempDir::new().unwrap();
let ws_dir = TempDir::new().unwrap();
// One global skill named "shared"
create_test_skill(global_dir.path(), "shared");
// Workspace skill with the SAME name — override
let ws_shared = ws_dir.path().join("shared");
std::fs::create_dir_all(&ws_shared).unwrap();
std::fs::write(
ws_shared.join("skill.toml"),
r#"
[skill]
name = "shared"
version = "2.0.0"
description = "Workspace override"
[runtime]
type = "python"
entry = "main.py"
[[tools.provided]]
name = "shared_tool"
description = "Workspace tool"
input_schema = { type = "object" }
"#,
)
.unwrap();
let mut registry = SkillRegistry::new(global_dir.path().to_path_buf());
registry.load_all().unwrap();
assert_eq!(registry.count(), 1, "One global skill loaded");
let ws_count = registry.load_workspace_skills(ws_dir.path()).unwrap();
// The return value must be 1, NOT 0.
// Before the #824 fix, doctor computed total(1) - bundled(1) = 0.
assert_eq!(
ws_count, 1,
"load_workspace_skills must report 1 even when overriding a global skill (#824)"
);
// Total registry count stays 1 because the override replaced, not added
assert_eq!(registry.count(), 1);
// But the skill is the workspace version
assert_eq!(
registry.get("shared").unwrap().manifest.skill.version,
"2.0.0",
"Workspace version should be active"
);
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ install() {
echo " Using specified version: $VERSION"
else
echo " Fetching latest release..."
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | head -1 | cut -d '"' -f 4)
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed 's/.*"tag_name": *"//' | sed 's/".*//')
fi
if [ -z "$VERSION" ]; then