fix(tinyflows): preserve explicit node timeouts, align discover timeout, log cap pins (#4877)

This commit is contained in:
Cyrus Gray
2026-07-15 12:41:54 +05:30
committed by GitHub
parent 5381c88a36
commit 490111ef2c
10 changed files with 214 additions and 63 deletions
@@ -157,6 +157,13 @@ pub(super) async fn run_autonomous(
// post-construction override rather than a pre-set on `config` (which
// the builder would otherwise clobber).
agent.set_max_tool_iterations(TASK_RUN_MAX_ITERATIONS);
tracing::debug!(
agent_id = %executor.agent_id,
run_id = %run_id,
max_tool_iterations = TASK_RUN_MAX_ITERATIONS,
"[task_dispatcher] pinned autonomous task-run iteration budget post-construction \
(overrides the session builder's per-definition cap)"
);
agent.set_event_context(run_id.to_string(), "task");
agent.set_agent_definition_name(format!(
"task-{}-{}",
+25 -2
View File
@@ -1039,19 +1039,41 @@ mod tests {
);
match &def.tools {
ToolScope::Named(names) => {
// Reconciled against `agent.toml`'s current `[tools].named`
// after the workflow-tools expansion PR widened the belt to
// agent-native editing/creation/run-control (`edit_workflow`,
// `validate_workflow`, `create_workflow`, `duplicate_flow`,
// `list_node_kinds`, `get_node_kind_contract`,
// `get_flow_history`, `list_flow_runs`, `resume_flow_run`,
// `cancel_flow_run`, `list_connectable_toolkits`) — these are
// the agent's own scoped tool surface, not the raw `flows_*`
// controller RPCs banned below, so the "no flow
// creation/enable via the raw controller" invariant still
// holds via the forbidden list.
let expected = [
"propose_workflow",
"revise_workflow",
"edit_workflow",
"validate_workflow",
"save_workflow",
"list_flows",
"get_flow",
"get_flow_history",
"get_flow_run",
"list_flow_connections",
"search_tool_catalog",
"get_tool_contract",
"get_tool_output_sample",
"list_agent_profiles",
"list_connectable_toolkits",
"list_node_kinds",
"get_node_kind_contract",
"dry_run_workflow",
"list_flow_runs",
"resume_flow_run",
"cancel_flow_run",
"create_workflow",
"duplicate_flow",
"run_flow",
"composio_list_toolkits",
"composio_list_connections",
@@ -1070,8 +1092,9 @@ mod tests {
expected.len(),
"workflow_builder scope must be EXACTLY the propose-or-read belt (got {names:?})"
);
// Hard exclusions: nothing that creates/enables a flow,
// executes raw integration actions, or touches the host.
// Hard exclusions: nothing that reaches the raw flow
// controller directly, executes raw integration actions, or
// touches the host.
// (Persistence onto an EXISTING flow is the deliberate
// `save_workflow` carve-out above; raw `flows_update` — which
// could also rename/re-gate arbitrary flows — stays out.)
+8 -1
View File
@@ -3297,7 +3297,14 @@ fn notify_pending_approval(flow: &Flow, thread_id: &str, pending_approvals: &[St
/// reasons read-only over the user's data and ends by emitting
/// `suggest_workflows`; its own `max_iterations` caps the loop, but a hung
/// LLM/tool call must never let the RPC block indefinitely.
const FLOW_DISCOVER_TIMEOUT_SECS: u64 = 300;
///
/// Matches [`FLOW_BUILD_TIMEOUT_SECS`] (600s): the session builder applies the
/// `flow_discovery` definition's `effective_max_iterations()` (50, not the
/// global default of 10) to this path (issue #4868), so a worst-case run at
/// ~10s/iteration can take up to ~500s — the old 300s bound could clip a
/// legitimate long discovery run before the iteration cap ever got a chance
/// to (post-merge Codex P2 finding).
const FLOW_DISCOVER_TIMEOUT_SECS: u64 = 600;
/// The canned brief handed to the `flow_discovery` agent. The agent's own
/// archetype prompt teaches the read → correlate → ground → emit loop; this is
+3 -1
View File
@@ -1694,6 +1694,7 @@ mod tests {
"resume",
"cancel_run",
"list_runs",
"list_all_runs",
"get_run",
"prune_runs",
"build",
@@ -1719,7 +1720,7 @@ mod tests {
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
assert_eq!(controllers.len(), 32);
assert_eq!(controllers.len(), 33);
let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect();
assert_eq!(
names,
@@ -1738,6 +1739,7 @@ mod tests {
"resume",
"cancel_run",
"list_runs",
"list_all_runs",
"get_run",
"prune_runs",
"build",
@@ -202,6 +202,12 @@ pub async fn spawn_workflow_run_background(
// a post-construction override rather than a pre-set on `config`
// (which the builder would otherwise clobber).
agent.set_max_tool_iterations(WORKFLOW_RUN_MAX_ITERATIONS);
tracing::debug!(
run_id = %run_id,
max_tool_iterations = WORKFLOW_RUN_MAX_ITERATIONS,
"[skills] workflow_run: pinned iteration budget post-construction (overrides \
the session builder's orchestrator definition cap)"
);
agent.set_event_context(run_id.clone(), "skill");
agent.set_agent_definition_name(format!(
"orchestrator-skill-{}",
+17 -6
View File
@@ -166,6 +166,13 @@ impl MemoryProfile {
warn!("[subconscious:memory] agent init failed: {e}");
format!("agent init: {e}")
})?;
// Stable per-tick correlation id — minted once and reused below for
// the pin log, the agent's event context, and the turn origin's
// `job_id`, so `mode`/`max_tool_iterations` (which repeat across
// ticks) don't leave concurrent/successive subconscious ticks
// indistinguishable in logs.
let tick_id = format!("subconscious:tick:{}", now_secs() as u64);
// Issue #4868 — `Agent::from_config` builds as the `orchestrator`
// definition (max_iterations=15, strict), so the session builder
// would stamp orchestrator's cap onto this agent regardless of mode
@@ -173,12 +180,16 @@ impl MemoryProfile {
// 30-iteration budget set above to 15. Re-apply the mode-specific
// cap post-construction so this tick keeps its previous behavior.
agent.set_max_tool_iterations(mode_iteration_cap);
agent.set_event_context(
format!("subconscious:tick:{}", now_secs() as u64),
"subconscious",
debug!(
tick_id = %tick_id,
"[subconscious:memory] pinned mode-specific iteration budget post-construction: \
mode={:?} max_tool_iterations={} (overrides the session builder's orchestrator \
definition cap)",
self.mode, mode_iteration_cap
);
agent.set_event_context(tick_id.clone(), "subconscious");
let mode_guidance = match self.mode {
SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => {
"\n\nYou may delegate deeper work with `spawn_async_subagent` (e.g. research \
@@ -201,10 +212,10 @@ impl MemoryProfile {
ticks. Do not invent busywork.{mode_guidance}",
);
debug!("[subconscious:memory] spawning decision agent");
debug!(tick_id = %tick_id, "[subconscious:memory] spawning decision agent");
let source = tick_origin_source(has_external_content);
let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation {
job_id: format!("subconscious:tick:{}", now_secs() as u64),
job_id: tick_id.clone(),
source,
};
let response = crate::openhuman::agent::turn_origin::with_origin(
+62 -4
View File
@@ -680,6 +680,35 @@ pub(crate) fn scale_timeout_for_iteration_cap(
}
}
/// Resolves the actual wall-clock timeout for one agent-node harness turn,
/// combining [`clamp_run_timeout_secs`] and [`scale_timeout_for_iteration_cap`]
/// per the post-merge Codex P2 finding on issue #4868's iteration-cap timeout
/// scaling: **an explicit `timeout_secs` the flow author set on the node must
/// never be scaled up.**
///
/// A node's `timeout_secs` can be an intentional fast-fail/SLA bound (e.g.
/// `timeout_secs: 120` to bound a health-check-style agent call) — scaling
/// that up to match a 50-iteration-cap agent would silently defeat the
/// author's explicit choice. So the iteration-cap scaling only ever widens
/// the *default* (no `timeout_secs` supplied) 240s bound; an explicit value is
/// clamped to `10..=600` (as it always was) and returned as-is.
///
/// `requested_timeout_secs` is the raw `request["timeout_secs"]` (before
/// clamping) so this function can distinguish "caller supplied a value" from
/// "caller supplied nothing" — [`clamp_run_timeout_secs`] alone collapses that
/// distinction into a plain `u64`.
pub(crate) fn resolve_run_timeout_secs(
requested_timeout_secs: Option<u64>,
effective_iteration_cap: usize,
) -> u64 {
let base_timeout_secs = clamp_run_timeout_secs(requested_timeout_secs);
if requested_timeout_secs.is_some() {
base_timeout_secs
} else {
scale_timeout_for_iteration_cap(base_timeout_secs, effective_iteration_cap)
}
}
/// Renders an agent-node completion `request` into the single user message
/// [`Agent::run_single`](crate::openhuman::agent::Agent::run_single) takes: the
/// `prompt` string when present and non-empty, else the `messages` array
@@ -923,19 +952,24 @@ impl OpenHumanAgentRunner {
let prompt = build_harness_run_prompt(&request);
let base_timeout_secs =
clamp_run_timeout_secs(request.get("timeout_secs").and_then(Value::as_u64));
let requested_timeout_secs = request.get("timeout_secs").and_then(Value::as_u64);
let base_timeout_secs = clamp_run_timeout_secs(requested_timeout_secs);
// Issue #4868 — the session builder now stamps `agent_ref`'s own
// `effective_max_iterations()` onto the agent (instead of the global
// default of 10), so `code_executor`/`tools_agent`/etc. can run up to
// 50 iterations here. Read the cap actually applied to `agent`
// (reflects the definition cap or the global fallback, whichever the
// builder resolved) and scale the timeout accordingly — see
// builder resolved) and scale the DEFAULT timeout accordingly — see
// `scale_timeout_for_iteration_cap`.
//
// Post-merge Codex P2 finding: an EXPLICIT `timeout_secs` the node
// config supplied is a caller-chosen bound (e.g. a fast-fail/SLA of
// 120s) and must be honored as-is, never scaled up just because the
// agent's iteration cap is high — see `resolve_run_timeout_secs`.
let effective_iteration_cap = agent.agent_config().max_tool_iterations;
let timeout_secs =
scale_timeout_for_iteration_cap(base_timeout_secs, effective_iteration_cap);
resolve_run_timeout_secs(requested_timeout_secs, effective_iteration_cap);
tracing::debug!(
target: "flows",
@@ -943,6 +977,7 @@ impl OpenHumanAgentRunner {
node_model = node_model.as_deref().unwrap_or("<definition-default>"),
default_model = effective.default_model.as_deref().unwrap_or("<config-default>"),
effective_iteration_cap,
explicit_timeout_secs = requested_timeout_secs.is_some(),
base_timeout_secs,
timeout_secs,
prompt_len = prompt.len(),
@@ -4165,6 +4200,29 @@ mod tests {
assert_eq!(scale_timeout_for_iteration_cap(240, 200), 600);
}
/// Post-merge Codex P2 finding on issue #4868: an explicit `timeout_secs`
/// the node config supplied (a caller-chosen fast-fail/SLA bound) must be
/// honored as-is — never scaled up just because the agent's iteration cap
/// is high — while the absence of one still gets the iteration-cap
/// scaling so a 50-iteration agent isn't killed by the 240s default.
#[test]
fn resolve_run_timeout_secs_preserves_an_explicit_request_even_for_a_high_cap_agent() {
assert_eq!(resolve_run_timeout_secs(Some(120), 50), 120);
}
#[test]
fn resolve_run_timeout_secs_scales_the_default_up_for_a_high_cap_agent() {
// No explicit timeout_secs (None) -> default 240s, scaled by the
// 50-iteration cap to min(50*12, 600) = 600.
assert_eq!(resolve_run_timeout_secs(None, 50), 600);
}
#[test]
fn resolve_run_timeout_secs_leaves_low_cap_agents_unscaled_either_way() {
assert_eq!(resolve_run_timeout_secs(None, 10), 240);
assert_eq!(resolve_run_timeout_secs(Some(120), 10), 120);
}
/// Regression for issue #4868: the agent-node runtime path
/// (`OpenHumanAgentRunner::run_via_harness`) must build an `Agent` that
/// carries `agent_ref`'s definition's effective cap (50 for an