fix: resolve 6 open bugs (#834, #805, #820, #848, #826, #836)

- #834: Remove 3 decommissioned Groq models (gemma2-9b-it, llama-3.2-1b/3b-preview)
- #805: Ollama streaming parser now checks both reasoning_content and reasoning fields
- #820: Browser Hand checks python3 before python, fix optional dep logic
- #848: Hand continuous interval changed from 60s to 3600s to prevent credit waste
- #826: Doctor command no longer reports all_ok when provider key is rejected
- #836: WebSocket tool events now include tool call ID for concurrent call correlation

All 825+ tests passing. Verified live with daemon.
This commit is contained in:
jaberjaber23
2026-03-26 03:33:07 +03:00
parent db86ff4ce3
commit 604e4ea7e3
12 changed files with 72 additions and 80 deletions
Generated
+14 -14
View File
@@ -3812,7 +3812,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"async-trait",
"axum",
@@ -3852,7 +3852,7 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"aes",
"async-trait",
@@ -3889,7 +3889,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"clap",
"clap_complete",
@@ -3916,7 +3916,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"axum",
"open",
@@ -3942,7 +3942,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"aes-gcm",
"argon2",
@@ -3970,7 +3970,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"chrono",
"dashmap",
@@ -3987,7 +3987,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"async-trait",
"chrono",
@@ -4025,7 +4025,7 @@ dependencies = [
[[package]]
name = "openfang-memory"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"async-trait",
"chrono",
@@ -4044,7 +4044,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4063,7 +4063,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"anyhow",
"async-trait",
@@ -4097,7 +4097,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"chrono",
"hex",
@@ -4120,7 +4120,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"async-trait",
"chrono",
@@ -4139,7 +4139,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.4.9"
version = "0.5.1"
dependencies = [
"async-trait",
"chrono",
@@ -8818,7 +8818,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.4.9"
version = "0.5.1"
[[package]]
name = "yoke"
+11 -3
View File
@@ -998,11 +998,12 @@ async fn handle_command(
fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_json::Value> {
match event {
StreamEvent::TextDelta { .. } => None, // Handled by debounce buffer
StreamEvent::ToolUseStart { name, .. } => Some(serde_json::json!({
StreamEvent::ToolUseStart { id, name, .. } => Some(serde_json::json!({
"type": "tool_start",
"id": id,
"tool": name,
})),
StreamEvent::ToolUseEnd { name, input, .. } if name == "canvas_present" => {
StreamEvent::ToolUseEnd { id, name, input, .. } if name == "canvas_present" => {
let html = input.get("html").and_then(|v| v.as_str()).unwrap_or("");
let title = input
.get("title")
@@ -1010,12 +1011,13 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.unwrap_or("Canvas");
Some(serde_json::json!({
"type": "canvas",
"id": id,
"canvas_id": uuid::Uuid::new_v4().to_string(),
"html": html,
"title": title,
}))
}
StreamEvent::ToolUseEnd { name, input, .. } => match verbose {
StreamEvent::ToolUseEnd { id, name, input, .. } => match verbose {
VerboseLevel::Off => None,
VerboseLevel::On => {
let input_preview: String = serde_json::to_string(input)
@@ -1025,6 +1027,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.collect();
Some(serde_json::json!({
"type": "tool_end",
"id": id,
"tool": name,
"input": input_preview,
}))
@@ -1037,18 +1040,21 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.collect();
Some(serde_json::json!({
"type": "tool_end",
"id": id,
"tool": name,
"input": input_preview,
}))
}
},
StreamEvent::ToolExecutionResult {
id,
name,
result_preview,
is_error,
} => match verbose {
VerboseLevel::Off => Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"is_error": is_error,
})),
@@ -1056,6 +1062,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
let truncated: String = result_preview.chars().take(200).collect();
Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"result": truncated,
"is_error": is_error,
@@ -1063,6 +1070,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
}
VerboseLevel::Full => Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"result": result_preview,
"is_error": is_error,
+9 -4
View File
@@ -2414,11 +2414,14 @@ decay_rate = 0.05
if !json {
ui::provider_status(name, env_var, true);
}
} else if !json {
ui::check_warn(&format!("{name} ({env_var}) - key rejected (401/403)"));
} else {
if !json {
ui::check_fail(&format!("{name} ({env_var}) - key rejected (401/403)"));
}
all_ok = false;
}
any_key_set = true;
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "warn" }, "live_test": !valid}));
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "fail" }, "live_test": !valid}));
} else {
if !json {
ui::provider_status(name, env_var, false);
@@ -2921,7 +2924,9 @@ decay_rate = 0.05
println!();
if all_ok {
ui::success("All checks passed! OpenFang is ready.");
ui::hint("Start the daemon: openfang start");
if find_daemon().is_none() {
ui::hint("Start the daemon: openfang start");
}
} else if repaired {
ui::success("Repairs applied. Re-run `openfang doctor` to verify.");
} else {
@@ -153,6 +153,7 @@ impl StandaloneChat {
name,
result_preview,
is_error,
..
} => {
self.chat.tool_result(&name, &result_preview, is_error);
}
+1
View File
@@ -1183,6 +1183,7 @@ impl App {
name,
result_preview,
is_error,
..
} => {
self.chat.tool_result(&name, &result_preview, is_error);
}
@@ -15,10 +15,10 @@ tools = [
[[requires]]
key = "python3"
label = "Python 3 must be installed"
label = "Python 3 must be installed (python3 or python)"
requirement_type = "binary"
check_value = "python3"
description = "Python 3 is required for installing and running the Playwright browser automation library. Python 3.8 or newer is recommended."
description = "Python 3 is required for installing and running the Playwright browser automation library. Python 3.8 or newer is recommended. Either 'python3' or 'python' (pointing to Python 3) will be detected."
[requires.install]
macos = "brew install python3"
@@ -26,7 +26,6 @@ windows = "winget install Python.Python.3.12"
linux_apt = "sudo apt install python3"
linux_dnf = "sudo dnf install python3"
linux_pacman = "sudo pacman -S python"
pip = "python3 --version"
manual_url = "https://www.python.org/downloads/"
estimated_time = "1-3 min"
+28 -11
View File
@@ -379,7 +379,12 @@ impl HandRegistry {
pub fn readiness(&self, hand_id: &str) -> Option<HandReadiness> {
let reqs = self.check_requirements(hand_id).ok()?;
let requirements_met = reqs.iter().all(|(_, ok)| *ok);
// Only non-optional requirements gate readiness.
// Optional requirements (e.g. chromium for browser hand) are nice-to-have;
// missing them results in "degraded" status but not "requirements not met".
let requirements_met = reqs
.iter()
.all(|(req, ok)| *ok || req.optional);
// A hand is active if at least one instance is in Active status.
let active = self
@@ -424,10 +429,13 @@ impl Default for HandRegistry {
fn check_requirement(req: &HandRequirement) -> bool {
match req.requirement_type {
RequirementType::Binary => {
// Special handling for python3: must actually run the command and verify
// the output contains "Python 3", because Windows ships a python3.exe
// Store shim that exists on PATH but doesn't actually work.
if req.check_value == "python3" {
// Special handling for python3 / python: must actually run the command
// and verify the output contains "Python 3", because:
// - Windows ships a python3.exe Store shim that doesn't actually work
// - Most modern Linux distros only ship "python3", not "python"
// - Some Docker images only have "python" pointing to Python 3
// Matches the detection logic in python_runtime.rs find_python_interpreter().
if req.check_value == "python3" || req.check_value == "python" {
return check_python3_available();
}
// Check if binary exists on PATH.
@@ -838,17 +846,26 @@ mod tests {
let reg = HandRegistry::new();
reg.load_bundled();
// Browser hand requires python3 + chromium. Activate it — if either
// requirement is unmet on this machine, it will show as degraded.
// Browser hand requires python3 (non-optional) + chromium (optional).
// requirements_met only reflects non-optional requirements.
// degraded = active + any requirement (including optional) unsatisfied.
let instance = reg.activate("browser", HashMap::new()).unwrap();
let r = reg.readiness("browser").unwrap();
assert!(r.active);
// If any requirement is not satisfied, degraded should be true
if !r.requirements_met {
assert!(r.degraded);
// Check individual requirements
let reqs = reg.check_requirements("browser").unwrap();
let python_met = reqs.iter().any(|(req, ok)| req.key == "python3" && *ok);
let chromium_met = reqs.iter().any(|(req, ok)| req.key == "chromium" && *ok);
// requirements_met only gates on non-optional (python3)
assert_eq!(r.requirements_met, python_met);
// degraded = active + any requirement unsatisfied
if python_met && chromium_met {
assert!(!r.degraded); // all met, not degraded
} else {
assert!(!r.degraded);
assert!(r.degraded); // something is missing, degraded
}
reg.deactivate(instance.instance_id).unwrap();
+2 -1
View File
@@ -3302,9 +3302,10 @@ impl OpenFangKernel {
}),
// Autonomous hands must run in Continuous mode so the background loop picks them up.
// Reactive (default) only fires on incoming messages, so autonomous hands would be inert.
// Default to 3600s (1 hour) to avoid wasting credits — see issue #848.
schedule: if def.agent.max_iterations.is_some() {
ScheduleMode::Continuous {
check_interval_secs: 60,
check_interval_secs: 3600,
}
} else {
ScheduleMode::default()
@@ -1780,6 +1780,7 @@ pub async fn run_agent_loop_streaming(
let preview: String = final_content.chars().take(300).collect();
if stream_tx
.send(StreamEvent::ToolExecutionResult {
id: tool_call.id.clone(),
name: tool_call.name.clone(),
result_preview: preview,
is_error: result.is_error,
@@ -1150,7 +1150,7 @@ impl LlmDriver for OpenAIDriver {
}
// Reasoning/thinking content delta (DeepSeek-R1, Qwen3 via LM Studio/Ollama)
if let Some(reasoning) = delta["reasoning_content"].as_str() {
if let Some(reasoning) = delta["reasoning_content"].as_str().or_else(|| delta["reasoning"].as_str()) {
if !reasoning.is_empty() {
reasoning_content.push_str(reasoning);
let _ = tx
@@ -135,6 +135,7 @@ pub enum StreamEvent {
},
/// Tool execution completed with result (emitted by agent loop, not LLM driver).
ToolExecutionResult {
id: String,
name: String,
result_preview: String,
is_error: bool,
+1 -43
View File
@@ -1548,34 +1548,6 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "llama-3.2-3b-preview".into(),
display_name: "Llama 3.2 3B".into(),
provider: "groq".into(),
tier: ModelTier::Fast,
context_window: 128_000,
max_output_tokens: 8_192,
input_cost_per_m: 0.06,
output_cost_per_m: 0.06,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "llama-3.2-1b-preview".into(),
display_name: "Llama 3.2 1B".into(),
provider: "groq".into(),
tier: ModelTier::Fast,
context_window: 128_000,
max_output_tokens: 8_192,
input_cost_per_m: 0.04,
output_cost_per_m: 0.04,
supports_tools: true,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "mixtral-8x7b-32768".into(),
display_name: "Mixtral 8x7B".into(),
@@ -1590,20 +1562,6 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
supports_streaming: true,
aliases: vec!["mixtral".into()],
},
ModelCatalogEntry {
id: "gemma2-9b-it".into(),
display_name: "Gemma 2 9B".into(),
provider: "groq".into(),
tier: ModelTier::Fast,
context_window: 8_192,
max_output_tokens: 4_096,
input_cost_per_m: 0.02,
output_cost_per_m: 0.02,
supports_tools: false,
supports_vision: false,
supports_streaming: true,
aliases: vec![],
},
ModelCatalogEntry {
id: "qwen-qwq-32b".into(),
display_name: "Qwen QWQ 32B".into(),
@@ -3924,7 +3882,7 @@ mod tests {
let anthropic = catalog.get_provider("anthropic").unwrap();
assert_eq!(anthropic.model_count, 7);
let groq = catalog.get_provider("groq").unwrap();
assert_eq!(groq.model_count, 10);
assert_eq!(groq.model_count, 7);
}
#[test]