Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/openhuman/flows/node_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ fn apply_host_overlay(contract: NodeKindContract) -> NodeKindContract {
woven into the prose. A prompt written as a =expression built from prose silently \
resolves to null and hands the agent an EMPTY prompt (rejected by the \
binding-resolvability gate).",
)
.with_note(
"execution=per_item runs a FULL harness agent (own model context, own tool loop) \
per input item, so it is far more expensive than a per_item tool_call — fan out \
over a list you have already narrowed, not a raw fetch. In THIS host \
simultaneous harness turns are additionally capped process-wide (8 by default, \
OPENHUMAN_FLOWS_MAX_PARALLEL_AGENTS): a higher config.concurrency is throttled \
to that ceiling, never rejected, so the run still completes.",
),
"tool_call" => contract
.with_note(
Expand Down
108 changes: 104 additions & 4 deletions src/openhuman/tinyflows/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +714 to +716

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep throttled fan-outs within the flow-wide deadline

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_body wraps the entire engine future in the fixed 600-second FLOW_RUN_TIMEOUT_SECS timeout (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 👍 / 👎.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.rs

Repository: 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.rs

Repository: tinyhumansai/openhuman

Length of output: 207


🌐 Web query:

Tokio 1.52.3 Semaphore MAX_PERMITS Semaphore::new panics permits greater than MAX_PERMITS

💡 Result:

In Tokio 1.52.3, the tokio::sync::Semaphore struct explicitly defines a MAX_PERMITS constant, which is set to usize::MAX >> 3 [1][2][3]. As documented, both the Semaphore::new(permits: usize) constructor and the add_permits(n: usize) method will panic if the number of permits requested or added exceeds this MAX_PERMITS limit [1][2][4]. This design is intended to prevent overflow and ensure the internal state of the semaphore remains valid [1][3].

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.rs

Repository: 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")
PY

Repository: tinyhumansai/openhuman

Length of output: 285


Reject permit counts above Semaphore::MAX_PERMITS.

usize::MAX passes the current check, but Semaphore::new panics for values above Semaphore::MAX_PERMITS. Bound the override and add a test for an out-of-range value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/tinyflows/caps.rs` around lines 740 - 743, Update
max_parallel_harness_agents to reject parsed values greater than
Semaphore::MAX_PERMITS, falling back to DEFAULT_MAX_PARALLEL_HARNESS_AGENTS just
like invalid or zero values. Add a test covering an out-of-range override and
verify the fallback prevents invalid semaphore permit counts.

Source: Coding guidelines

}

/// Which execution path an `agent_ref` routes to (see [`OpenHumanAgentRunner`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AgentRoute {
Expand Down Expand Up @@ -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(|_| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count semaphore waiting against the node timeout

When more than eight harness turns are active, or another flow occupies the global slots, this await can block before timeout_secs is read and before the tokio::time::timeout around run_single is created. Consequently, an agent node configured with a 10-second wall-clock timeout can wait for minutes and then receive an additional 10 seconds, violating the existing caller-chosen timeout contract; acquire the permit inside the same timeout budget or subtract the queue wait from the remaining duration.

Useful? React with 👍 / 👎.

EngineError::Capability(
"agent node: harness concurrency limiter closed unexpectedly".to_string(),
)
})?;

if let Some(c) = conn {
tracing::debug!(
target: "flows",
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.rs

Repository: 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 || true

Repository: 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
)))
PY

Repository: 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
PY

Repository: tinyhumansai/openhuman

Length of output: 531


Test a queued acquire, not try_acquire.

try_acquire() returns immediately and does not create a waiter. Start a third acquire_owned() while both permits are held, assert it remains pending, release a permit, then await it. The production path uses HARNESS_AGENT_SLOTS.acquire().await, so the test must cover this blocking behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/tinyflows/caps.rs` around lines 3867 - 3880, Update
the_harness_ceiling_throttles_rather_than_rejects to test queued acquisition:
start a third acquire_owned while both semaphore permits are held, verify it
remains pending, release one held permit, then await the queued permit
successfully. Remove the try_acquire assertions and preserve the existing
two-permit setup.

Source: Coding guidelines

drop(held2);
}
use super::*;
use crate::openhuman::agent::prompts::types::IntegrationConnection;
use crate::openhuman::composio::{ComposioExecuteResponse, ConnectedIntegration};
Expand Down
Loading