Files
openfang/crates/openfang-kernel/tests/multi_agent_test.rs
T
Ben Hoverter b1c4061247 fix(runtime): wire subprocess_timeout_secs through config.toml
Follow-up to 79aa34c. The previous commit added the public surface
(DriverConfig field + OPENFANG_SUBPROCESS_TIMEOUT_SECS env var) but
left every DriverConfig construction site hardcoded to None — so the
struct field was wired but had no on-disk source feeding it. The env
var was the only operator-facing knob.

This commit plumbs the missing layer: the timeout is now deserializable
from config.toml on both the primary and global-fallback providers.

Public surface
- DefaultModelConfig.subprocess_timeout_secs: Option<u64>
- FallbackProviderConfig.subprocess_timeout_secs: Option<u64>
- Both fields are #[serde(default)] — existing config.toml files
  without the field deserialize cleanly to None (no breaking change).

Placement rationale
- Per-provider on each config struct, not a top-level field or a new
  [driver] section. This matches the existing per-provider config shape
  and lets operators set different timeouts for primary vs. fallback
  (e.g. tighter timeout on a fast fallback to fail over sooner). If a
  second driver-level setting ever lands, refactoring two struct fields
  into a [driver] section is cheap; we don't pre-pay for it now.

Wiring (kernel.rs)
- L663  primary driver  ........  pulls config.default_model.subprocess_timeout_secs
- L687  auto-detect path  ......  inherits default_model intent (the swap
                                  is replacing the *provider*, not the
                                  timeout policy)
- L736  global fallback loop  ..  pulls fb.subprocess_timeout_secs
- L5031 agent primary  .........  inherits effective_default's value when
                                  agent_provider == default_provider;
                                  None for cross-provider overrides
- L5108 agent manifest fallback   inherits dm's value when the manifest
                                  fallback resolves to "default" (matching
                                  the existing fb.provider sentinel logic);
                                  None for explicit cross-provider entries
- L5139 global fallback (per-agent loop) — pulls fb.subprocess_timeout_secs

Sites kept as None (intentional)
- agent_loop.rs:1146, 1330: ModelNotFound recovery iterates over the
  agent manifest's fallback_models (FallbackModel, not the config-toml
  type) — no per-provider config in scope.
- routes.rs:7701: provider connectivity test endpoint; no config source.
- routes.rs:7529: dashboard hot-update path constructs a fresh DM with
  defaults (None) — operator sets timeout via config.toml, not via the
  set-key flow.

Tests
- test_subprocess_timeout_secs_in_toml: round-trips a TOML doc with
  default_model.subprocess_timeout_secs = 600 and one fallback at 180,
  one fallback omitted; asserts each value (or None) reaches the parsed
  config struct.
- test_subprocess_timeout_secs_omitted_defaults_to_none: asserts a
  legacy-shaped config.toml (no timeout fields) parses cleanly with
  both fields = None — backward-compat guard.
- 4 existing claude_code driver timeout tests still pass.

Mechanical pass-throughs
- 8 test fixtures across openfang-kernel/tests and openfang-api/tests
  gain subprocess_timeout_secs: None on their DefaultModelConfig
  literals.
- 1 production literal in routes.rs gains the same field.
- The existing FallbackProviderConfig serde-roundtrip test gains
  subprocess_timeout_secs: None plus an assertion.

Precedence comment in drivers/mod.rs::create_driver updated to reflect
that the config-field path is now real, with explicit pointers to the
kernel.rs wiring sites for future contributors.

Validated: cargo check --workspace --tests is clean; openfang-types
(362), openfang-runtime (933), and openfang-kernel (260) lib tests
all pass.
2026-04-27 23:40:12 -07:00

203 lines
5.9 KiB
Rust

//! Multi-agent integration test: spawn 6 agents, send messages, verify all respond.
//!
//! Run with: GROQ_API_KEY=gsk_... cargo test -p openfang-kernel --test multi_agent_test -- --nocapture
use openfang_kernel::OpenFangKernel;
use openfang_types::agent::AgentManifest;
use openfang_types::config::{DefaultModelConfig, KernelConfig};
fn test_config() -> KernelConfig {
let tmp = std::env::temp_dir().join("openfang-multi-agent-test");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
KernelConfig {
home_dir: tmp.clone(),
data_dir: tmp.join("data"),
default_model: DefaultModelConfig {
provider: "groq".to_string(),
model: "llama-3.3-70b-versatile".to_string(),
api_key_env: "GROQ_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
}
}
fn load_manifest(toml_str: &str) -> AgentManifest {
toml::from_str(toml_str).expect("Should parse manifest")
}
#[tokio::test]
async fn test_six_agent_fleet() {
if std::env::var("GROQ_API_KEY").is_err() {
eprintln!("GROQ_API_KEY not set, skipping multi-agent test");
return;
}
let kernel = OpenFangKernel::boot_with_config(test_config()).expect("Kernel should boot");
// Define all 6 agents with different roles and models
let agents = vec![
(
"coder",
r#"
name = "coder"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
system_prompt = "You are Coder. Reply with 'CODER:' prefix. Be concise."
[capabilities]
tools = ["file_read", "file_write"]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
"Write a one-line Rust function that adds two numbers.",
),
(
"researcher",
r#"
name = "researcher"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
system_prompt = "You are Researcher. Reply with 'RESEARCHER:' prefix. Be concise."
[capabilities]
tools = ["web_fetch"]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
"What is Rust's primary advantage over C++? One sentence.",
),
(
"writer",
r#"
name = "writer"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
system_prompt = "You are Writer. Reply with 'WRITER:' prefix. Be concise."
[capabilities]
tools = ["file_read", "file_write"]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
"Write a one-sentence tagline for an Agent Operating System.",
),
(
"ops",
r#"
name = "ops"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.1-8b-instant"
system_prompt = "You are Ops. Reply with 'OPS:' prefix. Be concise."
[capabilities]
tools = ["shell_exec"]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
"What would you check first if a server is running slowly?",
),
(
"analyst",
r#"
name = "analyst"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
system_prompt = "You are Analyst. Reply with 'ANALYST:' prefix. Be concise."
[capabilities]
tools = ["file_read"]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
"What are the top 3 metrics to track for an API service?",
),
(
"hello-world",
r#"
name = "hello-world"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.1-8b-instant"
system_prompt = "You are a friendly greeter. Reply with 'HELLO:' prefix. Be concise."
[capabilities]
memory_read = ["*"]
memory_write = ["self.*"]
"#,
"Greet the user in a fun way.",
),
];
println!("\n{}", "=".repeat(60));
println!(" OPENFANG MULTI-AGENT FLEET TEST");
println!(" Spawning {} agents...", agents.len());
println!("{}\n", "=".repeat(60));
// Spawn all agents
let mut agent_ids = Vec::new();
for (name, manifest_str, _) in &agents {
let manifest = load_manifest(manifest_str);
let id = kernel
.spawn_agent(manifest)
.unwrap_or_else(|e| panic!("Failed to spawn {name}: {e}"));
println!(" Spawned: {name:<12} -> {id}");
agent_ids.push(id);
}
assert_eq!(kernel.registry.count(), 6, "Should have 6 agents");
println!(
"\n All {} agents spawned. Sending messages...\n",
agents.len()
);
// Send messages to each agent sequentially (to respect Groq rate limits)
let mut results = Vec::new();
for (i, (name, _, message)) in agents.iter().enumerate() {
let result = kernel
.send_message(agent_ids[i], message)
.await
.unwrap_or_else(|e| panic!("Failed to message {name}: {e}"));
println!("--- {name} ---");
println!(" Q: {message}");
println!(" A: {}", result.response);
println!(
" [{} tokens in, {} tokens out, {} iters]",
result.total_usage.input_tokens, result.total_usage.output_tokens, result.iterations
);
println!();
assert!(
!result.response.is_empty(),
"{name} response should not be empty"
);
results.push(result);
}
// Summary
let total_input: u64 = results.iter().map(|r| r.total_usage.input_tokens).sum();
let total_output: u64 = results.iter().map(|r| r.total_usage.output_tokens).sum();
println!("============================================================");
println!(" FLEET SUMMARY");
println!(" Agents: {}", agents.len());
println!(" Total input: {} tokens", total_input);
println!(" Total output: {} tokens", total_output);
println!(" All responded: YES");
println!("============================================================");
// Cleanup
for id in agent_ids {
kernel.kill_agent(id).unwrap();
}
kernel.shutdown();
}