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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `zeph-core`: new `Channel::send_skill_catalog` method (default no-op) and
`AgentEvent::SkillCatalog`, forwarded by `AnyChannel`, `GatewayChannel`, and
`AppChannel`, overridden by `TuiChannel` to feed the mention picker's Skills tab.
- `zeph-subagent`, `zeph-config`, `zeph-core`: configurable, default-on session-wide
cumulative cap on total sub-agent spawns (issue #6545), independent of the existing
`max_concurrent` (in-flight) and `max_spawn_depth` (recursion) guardrails — catches a
shallow, low-concurrency but high-frequency sequential delegation loop that neither of
those bounds. New `[agents] max_spawns_per_session` config field (default `100`, `0` =
unlimited), new `SubAgentError::SessionSpawnLimit` variant, and a new
`zeph_subagent::SessionSpawnBudget` counter type (an uncloneable `AtomicUsize` newtype,
reached via an `Agent::session_budget()` accessor rather than a copied handle) so
`SubAgentManager::spawn`/`resume` and the ACP `/subagent spawn` path (which never touches
`SubAgentManager`) enforce the same cumulative counter. The cap is checked ahead of
`max_spawn_depth`/`max_concurrent` but
consumed only at each spawn path's true commit point, so a spawn rejected for any other
reason — including a transient `ConcurrencyLimit` the orchestration scheduler retries —
never burns budget it never used. `--migrate-config` populates the new key on existing
configs; `/agent status` and `/agent list` surface the running count.

### Changed

Expand Down
1 change: 1 addition & 0 deletions book/src/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ enabled = false # Enable native worktree isolation for backgr
[agents]
enabled = false # Enable sub-agent system (default: false)
max_concurrent = 1 # Max concurrent sub-agents (default: 1)
max_spawns_per_session = 100 # Cumulative spawn cap for the whole session (default: 100, 0 = unlimited)
delegation_mode = "proactive" # Autonomous spawn control: "proactive" (default), "explicit_request_only", or "disabled"
extra_dirs = [] # Additional directories to scan for agent definitions
# default_memory_scope = "project" # Default memory scope for agents without explicit `memory` field
Expand Down
5 changes: 5 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,11 @@ enabled = false
delegation_mode = "proactive"
# Maximum number of sub-agents that can run concurrently
max_concurrent = 1
# Maximum cumulative number of sub-agents that may be spawned within a single session,
# independent of max_concurrent (in-flight limit) and max_spawn_depth (recursion limit) —
# guards against a shallow, low-concurrency but high-frequency sequential delegation loop
# (issue #6545). 0 = unlimited.
max_spawns_per_session = 100
# Allow sub-agents to use bypass_permissions mode (enable only in trusted environments)
allow_bypass_permissions = false
# Enable writing JSONL transcripts for sub-agent sessions (required for /agent resume)
Expand Down
40 changes: 40 additions & 0 deletions crates/zeph-config/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ fn default_max_concurrent() -> usize {
5
}

fn default_max_spawns_per_session() -> usize {
100
}

fn default_context_window_turns() -> usize {
10
}
Expand Down Expand Up @@ -600,6 +604,7 @@ impl Default for TaskSupervisorConfig {
/// delegation_mode = "explicit_request_only"
/// max_concurrent = 3
/// max_spawn_depth = 2
/// max_spawns_per_session = 50
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
Expand All @@ -620,6 +625,15 @@ pub struct SubAgentConfig {
/// Maximum number of sub-agents that can run concurrently.
#[serde(default = "default_max_concurrent")]
pub max_concurrent: usize,
/// Maximum cumulative number of sub-agents that may be spawned within a single session,
/// independent of [`max_concurrent`][Self::max_concurrent] (in-flight limit) and
/// [`max_spawn_depth`][Self::max_spawn_depth] (recursion limit). Guards against a shallow,
/// low-concurrency but high-frequency sequential delegation loop that neither of those
/// limits would catch (issue #6545). `0` = unlimited. The counter resets at session start
/// and is shared across every spawn chokepoint (`SubAgentManager::spawn`/`resume` and the
/// ACP `/subagent spawn` path). Default: `100`.
#[serde(default = "default_max_spawns_per_session")]
pub max_spawns_per_session: usize,
/// Additional directories to search for `.agent.md` definition files.
pub extra_dirs: Vec<PathBuf>,
/// User-level agents directory.
Expand Down Expand Up @@ -718,6 +732,7 @@ impl Default for SubAgentConfig {
enabled: false,
delegation_mode: DelegationMode::default(),
max_concurrent: default_max_concurrent(),
max_spawns_per_session: default_max_spawns_per_session(),
extra_dirs: Vec::new(),
user_agents_dir: None,
default_permission_mode: None,
Expand Down Expand Up @@ -808,6 +823,31 @@ mod tests {
);
}

#[test]
fn subagent_config_max_spawns_per_session_default_direct() {
// Direct-Default assertion (distinct from the serde round-trip below): catches a
// forgotten `impl Default` entry, which the per-field serde attribute would otherwise
// mask on every serde-loaded path while every non-serde caller silently got `0`.
assert_eq!(SubAgentConfig::default().max_spawns_per_session, 100);
}

#[test]
fn subagent_config_max_spawns_per_session_omitted_key_defaults_100() {
// Serde round-trip: a present `[agents]` section with the key absent must not
// silently resolve to `0` (unlimited) — that would recreate the exact "safety net
// ships disabled" defect class #6469/PR #6528 fixed for the tool-call/cost guardrails.
let toml_str = "enabled = true\nmax_concurrent = 3";
let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.max_spawns_per_session, 100);
}

#[test]
fn subagent_config_deserialize_max_spawns_per_session() {
let toml_str = "max_spawns_per_session = 50";
let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.max_spawns_per_session, 50);
}

#[test]
fn subagent_config_delegation_mode_defaults_proactive() {
let cfg = SubAgentConfig::default();
Expand Down
31 changes: 17 additions & 14 deletions crates/zeph-config/src/migrate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ pub use memory::*;
pub use plugins::migrate_plugins_reputation_config;
pub use serve::migrate_serve_config;
pub use session::*;
pub use subagent::migrate_agents_delegation_mode;
pub use subagent::{migrate_agents_delegation_mode, migrate_agents_max_spawns_per_session};
pub use tools::*;

/// Returns `true` when `name` is an active (non-commented) TOML section header in `src`.
Expand Down Expand Up @@ -608,19 +608,19 @@ mod steps;
use steps::{
MigrateA2aCardTrustConfig, MigrateA2aServerRemoveInertFields, MigrateAcpAuthClientsConfig,
MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
MigrateAgentTimeReminder, MigrateAgentsDelegationMode, MigrateAutodreamConfig,
MigrateCavemanConfig, MigrateCocoonProviderNotice, MigrateCocoonShowBalance,
MigrateCompressionPredictorConfig, MigrateDatabaseUrl, MigrateDeepLinkConfig,
MigrateDurableConfig, MigrateDurableHwmAdvisory, MigrateDurableKeyRotation,
MigrateDurableSharedDb, MigrateDurableStaleRunningAfterSecs, MigrateEgressConfig,
MigrateEmbedProviderRename, MigrateEvalModelToProvider, MigrateFidelityTimeoutDefaults,
MigrateFiveSignalConfig, MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig,
MigrateGoalsConfig, MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig,
MigrateHooksTurnComplete, MigrateIntegrityConfig, MigrateKnowledgeConfig,
MigrateLlmStreamLimits, MigrateMagicDocsConfig, MigrateMcpElicitationConfig,
MigrateMcpMaxConnectAttempts, MigrateMcpMediaConfig, MigrateMcpRetryAndToolTimeout,
MigrateMcpTrustLevels, MigrateMemoryConsentGateConfig, MigrateMemoryGraph,
MigrateMemoryGraphRecallIncludeImported, MigrateMemoryHebbian,
MigrateAgentTimeReminder, MigrateAgentsDelegationMode, MigrateAgentsMaxSpawnsPerSession,
MigrateAutodreamConfig, MigrateCavemanConfig, MigrateCocoonProviderNotice,
MigrateCocoonShowBalance, MigrateCompressionPredictorConfig, MigrateDatabaseUrl,
MigrateDeepLinkConfig, MigrateDurableConfig, MigrateDurableHwmAdvisory,
MigrateDurableKeyRotation, MigrateDurableSharedDb, MigrateDurableStaleRunningAfterSecs,
MigrateEgressConfig, MigrateEmbedProviderRename, MigrateEvalModelToProvider,
MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig, MigrateFocusAutoConsolidateMinWindow,
MigrateForgettingConfig, MigrateGoalsConfig, MigrateGonkagateToGonka,
MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, MigrateIntegrityConfig,
MigrateKnowledgeConfig, MigrateLlmStreamLimits, MigrateMagicDocsConfig,
MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpMediaConfig,
MigrateMcpRetryAndToolTimeout, MigrateMcpTrustLevels, MigrateMemoryConsentGateConfig,
MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported, MigrateMemoryHebbian,
MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig,
MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval,
MigrateMemoryRetrievalQueryBias, MigrateMemoryStoreConfig, MigrateMemoryTypeAwareCompose,
Expand Down Expand Up @@ -860,6 +860,9 @@ pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>
// Step 104 — add expandable_blockquote_min_lines advisory comment to an
// existing active [telegram] table (spec 007-3-telegram-rich-text, #6541)
Box::new(MigrateTelegramExpandableBlockquoteConfig),
// Step 105 — insert active max_spawns_per_session = 100 into an existing
// [agents] table with enabled = true and no max_spawns_per_session key (#6545)
Box::new(MigrateAgentsMaxSpawnsPerSession),
]
});

Expand Down
18 changes: 16 additions & 2 deletions crates/zeph-config/src/migrate/steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ use super::{
MigrateError, Migration, MigrationResult, migrate_a2a_card_trust_config,
migrate_a2a_server_remove_inert_fields, migrate_acp_auth_clients_config,
migrate_acp_subagents_config, migrate_agent_budget_hint, migrate_agent_retry_to_tools_retry,
migrate_agent_time_reminder, migrate_agents_delegation_mode, migrate_autodream_config,
migrate_caveman_config, migrate_cocoon_provider_notice, migrate_cocoon_show_balance,
migrate_agent_time_reminder, migrate_agents_delegation_mode,
migrate_agents_max_spawns_per_session, migrate_autodream_config, migrate_caveman_config,
migrate_cocoon_provider_notice, migrate_cocoon_show_balance,
migrate_compression_predictor_config, migrate_database_url, migrate_deep_link_config,
migrate_durable_config, migrate_durable_hwm_advisory, migrate_durable_key_rotation,
migrate_durable_shared_db, migrate_durable_stale_running_after_secs, migrate_egress_config,
Expand Down Expand Up @@ -1341,3 +1342,16 @@ impl Migration for MigrateTelegramExpandableBlockquoteConfig {
migrate_telegram_expandable_blockquote_config(toml_src)
}
}

/// Step 105 — insert an active `max_spawns_per_session = 100` value into an existing
/// `[agents]` table with `enabled = true` and no `max_spawns_per_session` key (issue #6545).
pub(super) struct MigrateAgentsMaxSpawnsPerSession;
impl Migration for MigrateAgentsMaxSpawnsPerSession {
fn name(&self) -> &'static str {
"migrate_agents_max_spawns_per_session"
}

fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError> {
migrate_agents_max_spawns_per_session(toml_src)
}
}
110 changes: 110 additions & 0 deletions crates/zeph-config/src/migrate/subagent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,67 @@ pub fn migrate_agents_delegation_mode(toml_src: &str) -> Result<MigrationResult,
})
}

/// Insert `max_spawns_per_session = 100` under `[agents]` when `enabled = true` and the key is
/// absent (issue #6545).
///
/// Mirrors [`migrate_agents_delegation_mode`] exactly: `#[serde(default = "...")]` already
/// makes the field's absence safe on load (see `SubAgentConfig::max_spawns_per_session`'s doc
/// comment), so this insertion is behaviorally a no-op — its purpose is discoverability, so an
/// operator who already opted into `enabled = true` sees the active session-wide spawn cap in
/// their config file rather than having it resolved silently.
///
/// No-op when `[agents]` is absent, not active (only commented-out), `enabled` is not `true`,
/// or `max_spawns_per_session` is already present.
///
/// # Errors
///
/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
pub fn migrate_agents_max_spawns_per_session(
toml_src: &str,
) -> Result<MigrationResult, MigrateError> {
if toml_src.contains("max_spawns_per_session") || !section_header_present(toml_src, "agents") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}

let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
let Some(agents_table) = doc
.get_mut("agents")
.and_then(toml_edit::Item::as_table_mut)
else {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
};

let enabled = agents_table
.get("enabled")
.and_then(toml_edit::Item::as_value)
.and_then(toml_edit::Value::as_bool)
.unwrap_or(false);

if !enabled {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}

agents_table.insert("max_spawns_per_session", toml_edit::value(100_i64));

Ok(MigrationResult {
output: doc.to_string(),
changed_count: 1,
sections_changed: vec!["agents.max_spawns_per_session".to_owned()],
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -122,4 +183,53 @@ mod tests {
assert_eq!(twice.changed_count, 0);
assert_eq!(twice.output, once.output);
}

#[test]
fn max_spawns_inserts_100_when_enabled_and_key_absent() {
let src = "[agents]\nenabled = true\nmax_concurrent = 3\n";
let result = migrate_agents_max_spawns_per_session(src).expect("migrate");
assert_eq!(result.changed_count, 1);
assert!(result.output.contains("max_spawns_per_session = 100"));
}

#[test]
fn max_spawns_noop_when_disabled() {
let src = "[agents]\nenabled = false\n";
let result = migrate_agents_max_spawns_per_session(src).expect("migrate");
assert_eq!(result.changed_count, 0);
assert!(!result.output.contains("max_spawns_per_session"));
}

#[test]
fn max_spawns_noop_when_key_already_present() {
let src = "[agents]\nenabled = true\nmax_spawns_per_session = 50\n";
let result = migrate_agents_max_spawns_per_session(src).expect("migrate");
assert_eq!(result.changed_count, 0);
assert_eq!(result.output, src);
}

#[test]
fn max_spawns_noop_when_section_absent() {
let src = "[llm]\nprovider = \"claude\"\n";
let result = migrate_agents_max_spawns_per_session(src).expect("migrate");
assert_eq!(result.changed_count, 0);
assert_eq!(result.output, src);
}

#[test]
fn max_spawns_noop_when_section_only_commented_out() {
let src = "# [agents]\n# enabled = true\n";
let result = migrate_agents_max_spawns_per_session(src).expect("migrate");
assert_eq!(result.changed_count, 0);
assert_eq!(result.output, src);
}

#[test]
fn max_spawns_idempotent() {
let src = "[agents]\nenabled = true\n";
let once = migrate_agents_max_spawns_per_session(src).expect("migrate");
let twice = migrate_agents_max_spawns_per_session(&once.output).expect("migrate");
assert_eq!(twice.changed_count, 0);
assert_eq!(twice.output, once.output);
}
}
42 changes: 39 additions & 3 deletions crates/zeph-config/src/migrate/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use super::*;
fn migrations_registry_has_all_steps() {
assert_eq!(
MIGRATIONS.len(),
104,
"MIGRATIONS registry must contain all 104 sequential steps"
105,
"MIGRATIONS registry must contain all 105 sequential steps"
);
for m in MIGRATIONS.iter() {
assert!(
Expand Down Expand Up @@ -2124,7 +2124,42 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {

#[test]
fn registry_has_fifty_entries() {
assert_eq!(MIGRATIONS.len(), 104);
assert_eq!(MIGRATIONS.len(), 105);
}

/// SC-003 (issue #6545): the isolated `migrate_agents_max_spawns_per_session` tests in
/// `migrate/subagent.rs` prove the function itself works, but not that it is actually wired
/// into `MIGRATIONS` — a function that exists but is never pushed into the registry (the
/// repo's recurring wire-X defect class) would pass those tests while silently doing nothing
/// for a real `--migrate-config` run.
///
/// Deliberately does **not** use `ConfigMigrator::migrate` — that type is the separate
/// catch-all reference-merge pass (`migrate/mod.rs`, diffs against the embedded
/// `config/default.toml` and appends missing keys as *commented-out* advisory lines via
/// `merge_table_commented`); it never reads `MIGRATIONS` at all. Using it here would pass even
/// if `MigrateAgentsMaxSpawnsPerSession` were never pushed into the registry — the catch-all
/// pass would still add `# max_spawns_per_session = 100` as a comment, and a naive
/// `str::contains` assertion cannot tell an active key from a commented one. Instead this
/// mirrors the real `--migrate-config` flow's first phase (`src/commands/migrate.rs`,
/// `handle_migrate_config`): fold `MIGRATIONS.iter()` over the input, then parse the result and
/// assert the key is present as an *active*, typed value — a parsed-document assertion cannot
/// match a comment, unlike a substring check.
#[test]
fn full_registry_adds_max_spawns_per_session_to_legacy_agents_config() {
let legacy = "[agents]\nenabled = true\nmax_concurrent = 3\n";
let mut current = legacy.to_owned();
for m in MIGRATIONS.iter() {
current = m
.apply(&current)
.expect("registry migration must not fail")
.output;
}
let doc: toml_edit::DocumentMut = current.parse().expect("migrated output must be valid TOML");
assert_eq!(
doc["agents"]["max_spawns_per_session"].as_integer(),
Some(100),
"MIGRATIONS must add an active (non-commented) key, got: {current}"
);
}

#[test]
Expand Down Expand Up @@ -2270,6 +2305,7 @@ fn registry_preserves_order_matches_dispatch() {
"migrate_agents_delegation_mode",
"migrate_memory_consent_gate_config",
"migrate_telegram_expandable_blockquote_config",
"migrate_agents_max_spawns_per_session",
];
let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
assert_eq!(actual, expected);
Expand Down
3 changes: 3 additions & 0 deletions crates/zeph-core/config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,9 @@ enabled = false
delegation_mode = "proactive"
# Maximum number of sub-agents that can run concurrently
max_concurrent = 1
# Maximum cumulative number of sub-agents that may be spawned within a single session,
# independent of max_concurrent and max_spawn_depth (issue #6545). 0 = unlimited.
max_spawns_per_session = 100
# Allow sub-agents to use bypass_permissions mode (enable only in trusted environments)
allow_bypass_permissions = false
# Enable writing JSONL transcripts for sub-agent sessions (required for /agent resume)
Expand Down
Loading
Loading