-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(flows): per-item fan-out — run an array of flow work concurrently #5302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -683,15 +683,66 @@ impl LlmProvider for OpenHumanLlm { | |
| /// | ||
| /// **Per-item cost.** In per-item execution mode the engine calls | ||
| /// [`run_agent`](AgentRunner::run_agent) once per input item, so a full harness | ||
| /// turn (with memory injection) fans out one `Agent` per item. The batch size is | ||
| /// not visible inside a single `run_agent` call (the engine drives the fan-out), | ||
| /// so a "> 25 items" warning is not reachable here; it belongs to a future | ||
| /// host-side per-item guard. Memory injection per node turn is accepted for this | ||
| /// turn (with memory injection) fans out one `Agent` per item. Since the engine | ||
| /// gained bounded per-item concurrency those calls also arrive *simultaneously*, | ||
| /// so the host-side guard this doc used to defer is now | ||
| /// [`HARNESS_AGENT_SLOTS`] — see it for why the engine's own `concurrency` | ||
| /// bound is not enough. Memory injection per node turn is accepted for this | ||
| /// first cut (skip-memory is a follow-up). | ||
| /// | ||
| /// **Concurrency safety.** `run_agent` is re-entrant by construction: it builds | ||
| /// a fresh [`Agent`](crate::openhuman::agent::Agent) per call and stamps any | ||
| /// model override onto a *cloned* `Config`, so concurrent calls never mutate | ||
| /// shared state. The origin escalation and approval-run context are task-locals | ||
| /// propagated by the engine's `buffer_unordered` (which polls every item on the | ||
| /// caller's task), so HITL gating still applies to every fanned-out turn. | ||
| pub struct OpenHumanAgentRunner { | ||
| pub config: Arc<Config>, | ||
| } | ||
|
|
||
| /// Process-wide ceiling on **simultaneous harness agent turns started by flow | ||
| /// nodes**, honoured by [`run_via_harness`](OpenHumanAgentRunner::run_via_harness). | ||
| /// | ||
| /// The engine's per-node `concurrency` bounds one node's fan-out; this bounds | ||
| /// the host. Those are different limits and the host one is the load-bearing | ||
| /// one: a graph can fan out `concurrency: "all"` over a 200-item array, and | ||
| /// several nodes (or several concurrent runs) can be fanning out at once, so | ||
| /// without a shared ceiling a single workflow could open hundreds of full agent | ||
| /// sessions — each with its own model context and tool loop — and exhaust | ||
| /// memory or the inference provider's rate limit. | ||
| /// | ||
| /// Default 8; override with `OPENHUMAN_FLOWS_MAX_PARALLEL_AGENTS`. Waiting on a | ||
| /// permit is *not* an error — an over-wide fan-out is throttled to this width | ||
| /// rather than rejected, so the workflow still completes, just more slowly. | ||
| static HARNESS_AGENT_SLOTS: std::sync::LazyLock<tokio::sync::Semaphore> = | ||
| std::sync::LazyLock::new(|| { | ||
| let permits = max_parallel_harness_agents( | ||
| std::env::var("OPENHUMAN_FLOWS_MAX_PARALLEL_AGENTS") | ||
| .ok() | ||
| .as_deref(), | ||
| ); | ||
| tracing::debug!( | ||
| target: "flows", | ||
| permits, | ||
| "[flows] agent_runner: harness concurrency ceiling" | ||
| ); | ||
| tokio::sync::Semaphore::new(permits) | ||
| }); | ||
|
|
||
| /// Default value for [`HARNESS_AGENT_SLOTS`]. | ||
| const DEFAULT_MAX_PARALLEL_HARNESS_AGENTS: usize = 8; | ||
|
|
||
| /// Resolves the harness concurrency ceiling from the raw env-var value. | ||
| /// | ||
| /// A malformed or zero override falls back to the default rather than erroring | ||
| /// or, worse, yielding a zero-permit semaphore that would deadlock every flow | ||
| /// agent node in the process. | ||
| fn max_parallel_harness_agents(raw: Option<&str>) -> usize { | ||
| raw.and_then(|v| v.trim().parse::<usize>().ok()) | ||
| .filter(|n| *n > 0) | ||
| .unwrap_or(DEFAULT_MAX_PARALLEL_HARNESS_AGENTS) | ||
|
Comment on lines
+740
to
+743
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Confirm the repository's Tokio version before using Semaphore::MAX_PERMITS.
rg -n -A4 -B2 '^name = "tokio"$' Cargo.lock
rg -n -C3 'max_parallel_harness_agents|Semaphore::new' src/openhuman/tinyflows/caps.rsRepository: tinyhumansai/openhuman Length of output: 2574 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Tokio declarations and feature sources ---'
rg -n -C4 'tokio\s*=|tokio::sync::Semaphore|MAX_PERMITS' --glob 'Cargo.toml' --glob 'Cargo.lock' --glob '*.rs'
printf '%s\n' '--- Relevant implementation and tests ---'
sed -n '700,750p' src/openhuman/tinyflows/caps.rs
sed -n '3835,3885p' src/openhuman/tinyflows/caps.rsRepository: tinyhumansai/openhuman Length of output: 207 🌐 Web query:
💡 Result: In Tokio 1.52.3, the Citations:
🏁 Script executed: #!/bin/bash
set -u
printf '%s\n' '--- Tokio references ---'
rg -n -C3 'tokio|Semaphore|MAX_PERMITS' --glob 'Cargo.toml' --glob 'Cargo.lock' --glob '*.rs' . || true
printf '%s\n' '--- Concurrency parser and initialization ---'
sed -n '710,750p' src/openhuman/tinyflows/caps.rs
printf '%s\n' '--- Parser tests ---'
sed -n '3838,3870p' src/openhuman/tinyflows/caps.rsRepository: tinyhumansai/openhuman Length of output: 50379 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import sys
usize_max = (1 << (8 * 8)) - 1 # repository targets use 64-bit usize
tokio_max_permits = usize_max >> 3
assert usize_max > tokio_max_permits
assert int("18446744073709551615") > tokio_max_permits
print(f"usize::MAX = {usize_max}")
print(f"Semaphore::MAX_PERMITS = {tokio_max_permits}")
print("usize::MAX is out of range for Semaphore::new")
PYRepository: tinyhumansai/openhuman Length of output: 285 Reject permit counts above
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
|
|
||
| /// Which execution path an `agent_ref` routes to (see [`OpenHumanAgentRunner`]). | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub(crate) enum AgentRoute { | ||
|
|
@@ -1098,6 +1149,17 @@ impl OpenHumanAgentRunner { | |
| ) -> Result<Value> { | ||
| use crate::openhuman::agent::Agent; | ||
|
|
||
| // Hold a slot for the whole turn: a fanned-out node can call this | ||
| // hundreds of times at once, and each call below builds a full agent | ||
| // session. Acquired before any work so waiters queue rather than pile | ||
| // up half-built agents. `_permit` is released on drop at end of scope, | ||
| // including on every early return below. | ||
| let _permit = HARNESS_AGENT_SLOTS.acquire().await.map_err(|_| { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When more than eight harness turns are active, or another flow occupies the global slots, this await can block before Useful? React with 👍 / 👎. |
||
| EngineError::Capability( | ||
| "agent node: harness concurrency limiter closed unexpectedly".to_string(), | ||
| ) | ||
| })?; | ||
|
|
||
| if let Some(c) = conn { | ||
| tracing::debug!( | ||
| target: "flows", | ||
|
|
@@ -3780,6 +3842,44 @@ pub fn open_flow_checkpointer( | |
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
|
|
||
| // --- harness fan-out concurrency ceiling --- | ||
|
|
||
| #[test] | ||
| fn harness_ceiling_defaults_when_unset_or_nonsense() { | ||
| // A malformed override must never produce a zero-permit semaphore — | ||
| // that would deadlock every flow agent node in the process. | ||
| for raw in [None, Some(""), Some("0"), Some("-4"), Some("lots")] { | ||
| assert_eq!( | ||
| super::max_parallel_harness_agents(raw), | ||
| super::DEFAULT_MAX_PARALLEL_HARNESS_AGENTS, | ||
| "{raw:?} should fall back to the default" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn harness_ceiling_honours_a_valid_override() { | ||
| assert_eq!(super::max_parallel_harness_agents(Some("3")), 3); | ||
| assert_eq!(super::max_parallel_harness_agents(Some(" 16 ")), 16); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn the_harness_ceiling_throttles_rather_than_rejects() { | ||
| // The contract the fan-out relies on: an over-wide batch waits for a | ||
| // slot and still completes. If this ever started returning an error | ||
| // instead, `concurrency: "all"` over a large array would fail the run | ||
| // rather than run it more slowly. | ||
| let slots = tokio::sync::Semaphore::new(2); | ||
| let held = slots.acquire().await.expect("first permit"); | ||
| let held2 = slots.acquire().await.expect("second permit"); | ||
| assert_eq!(slots.available_permits(), 0); | ||
| // A third acquirer is pending, not refused. | ||
| assert!(slots.try_acquire().is_err(), "no permits left"); | ||
| drop(held); | ||
| assert!(slots.try_acquire().is_ok(), "a released slot is reusable"); | ||
|
Comment on lines
+3867
to
+3880
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect whether this test creates a waiting acquisition.
rg -n -C4 'the_harness_ceiling_throttles|try_acquire|acquire_owned' src/openhuman/tinyflows/caps.rsRepository: tinyhumansai/openhuman Length of output: 1222 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- target test and surrounding module ---'
sed -n '3825,3905p' src/openhuman/tinyflows/caps.rs
printf '%s\n' '--- semaphore usage in the module ---'
rg -n -C5 'Semaphore|acquire|try_acquire|concurrency' src/openhuman/tinyflows/caps.rs
printf '%s\n' '--- Tokio dependency declarations ---'
rg -n -C3 'tokio' Cargo.toml app/Cargo.toml app/src-tauri/Cargo.toml 2>/dev/null || trueRepository: tinyhumansai/openhuman Length of output: 18456 🏁 Script executed: python3 - <<'PY'
from pathlib import Path
import re
path = Path("src/openhuman/tinyflows/caps.rs")
text = path.read_text()
match = re.search(
r'async fn the_harness_ceiling_throttles_rather_than_rejects\(\) \{(.*?)\n \}',
text,
re.S,
)
assert match, "target test not found"
body = match.group(1)
print("test_has_try_acquire:", "try_acquire" in body)
print("test_has_queued_acquire:", bool(re.search(r"(acquire_owned|spawn|acquire\(\))", body)))
print("test_releases_before_final_try:", body.find("drop(held)") < body.rfind("try_acquire"))
print("production_acquires_waiting_permit:", bool(re.search(
r'HARNESS_AGENT_SLOTS\.acquire\(\)\.await', text
)))
PYRepository: tinyhumansai/openhuman Length of output: 295 🏁 Script executed: python3 - <<'PY'
from pathlib import Path
import re
text = Path("src/openhuman/tinyflows/caps.rs").read_text()
match = re.search(
r'async fn the_harness_ceiling_throttles_rather_than_rejects\(\) \{(.*?)\n \}',
text,
re.S,
)
assert match, "target test not found"
body = match.group(1)
lines = body.splitlines()
exact_acquire_calls = [
line.strip() for line in lines
if re.search(r'\bslots\.acquire\s*\(', line)
]
owned_acquire_calls = [
line.strip() for line in lines
if re.search(r'\b(?:slots\.)?acquire_owned\s*\(', line)
]
try_acquire_calls = [
line.strip() for line in lines
if re.search(r'\bslots\.try_acquire\s*\(', line)
]
zero_index = next(
i for i, line in enumerate(lines)
if "available_permits(), 0" in line
)
post_zero_exact_acquires = [
line.strip() for line in lines[zero_index + 1:]
if re.search(r'\bslots\.acquire\s*\(', line)
]
print("exact_slots_acquire_calls:", exact_acquire_calls)
print("acquire_owned_calls:", owned_acquire_calls)
print("try_acquire_calls:", try_acquire_calls)
print("exact_slots_acquires_after_zero_permits:", post_zero_exact_acquires)
assert len(exact_acquire_calls) == 2
assert not owned_acquire_calls
assert len(try_acquire_calls) == 2
assert not post_zero_exact_acquires
PYRepository: tinyhumansai/openhuman Length of output: 531 Test a queued
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| drop(held2); | ||
| } | ||
| use super::*; | ||
| use crate::openhuman::agent::prompts::types::IntegrationConnection; | ||
| use crate::openhuman::composio::{ComposioExecuteResponse, ConnectedIntegration}; | ||
|
|
||
| +25 −0 | README.md | |
| +132 −1 | src/catalog.rs | |
| +15 −9 | src/nodes/integration/agent.rs | |
| +14 −9 | src/nodes/integration/http_request.rs | |
| +14 −9 | src/nodes/integration/memory.rs | |
| +360 −105 | src/nodes/integration/sub_workflow.rs | |
| +14 −9 | src/nodes/integration/tool_call.rs | |
| +657 −0 | src/nodes/map.rs | |
| +6 −0 | src/nodes/mod.rs | |
| +244 −0 | src/validate.rs | |
| +297 −0 | tests/per_item_fanout_e2e.rs | |
| +44 −0 | wiki/Node-Catalog.md |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a per-item agent batch needs more than 600 seconds of aggregate work at eight slots—for example, 40 items averaging over two minutes, or when unrelated runs already occupy the process-wide permits—the queued calls cannot merely complete more slowly:
run_flow_bodywraps the entire engine future in the fixed 600-secondFLOW_RUN_TIMEOUT_SECStimeout (src/openhuman/flows/ops.rs), so it cancels the remaining items and marks the run failed. Reconcile this queue with the overall deadline or reject an infeasible fan-out before starting rather than promising that throttling always completes.Useful? React with 👍 / 👎.