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
12 changes: 12 additions & 0 deletions .zeph/skills/setup-guide/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,18 @@ export ZEPH_AGENTS_FORWARD_TRANSCRIPT=true
```
Opt-in, default `false` (`agents.forward_transcript` in config). Also settable via `--forward-subagent-text`. No effect unless a consumer surface (`--tui` or `--bare`) is active for the session.

Delegation mode — control whether the main agent may spawn sub-agents, and who may trigger it:
```bash
export ZEPH_AGENTS_DELEGATION_MODE=explicit_request_only
```
One of `disabled` (no spawn from any code path), `explicit_request_only` (only direct user
actions — `/agent spawn`, `/agent resume`, `/subagent spawn` — the main agent's own planner/
scheduler may never spawn autonomously), or `proactive` (default; both explicit and autonomous
spawns allowed). Orthogonal to `agents.enabled`, which remains the outer kill switch:
`enabled = false` always behaves as `disabled` regardless of this setting. Also settable via
`--delegation-mode`. Useful to restrict in semi-trusted channels (Telegram/Discord/webhook
ingestion) where prompt-injected input could otherwise trigger unsupervised delegation.

## Security

Secret redaction:
Expand Down
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
existed to support — `zeph-agent-tools` now has zero workspace or external dependencies.
Behavior of `doom_loop_hash` and its call site are unchanged.

### Added

- `zeph-subagent`: a running subagent's live transcript forwarding (`forward_transcript`,
issue #6359) now streams text and thinking output as token-level deltas *within* a turn
instead of only once the turn completes (#6456, FR-002b), on providers whose native
streaming-with-tools path is available (currently Claude, via the existing
`AnyProvider::chat_with_tools_stream`/`ToolSseStream`). `agent_loop.rs` drives the stream
and forwards each `ContentChunk`/`ThinkingChunk` immediately through the same tail-drop
forwarding channel `send_text`/`send_thinking` already used for FR-002a, while assembling
the identical `ChatResponse` shape `chat_with_tools` would have returned. No config change
is required — this is strictly finer-grained delivery under the existing
`forward_transcript` flag. Deltas remain ephemeral and display-only (tail-droppable under
backpressure, same as before): the accumulated response text and the guaranteed terminal
chunk remain the sole source of truth for the next turn's LLM context. Providers without a
native tool-streaming implementation continue to forward the full per-turn text once, at
turn end, exactly as before (zero-regression fallback, no new code path per backend). The
forwarding drain (`forward.rs`) now buffers each streamed delta with a bounded 256-byte
holdback window before sanitizing and emitting it, instead of sanitizing every delta in
complete isolation — closes a masking gap where a secret or PII pattern split across two
delta boundaries matched neither fragment individually and reached `--bare` stdout / the
TUI ring buffer unmasked. Buffered content is only ever delayed, never dropped: any
remaining held-back tail is flushed in full immediately before the terminal chunk.

- `zeph-config`/`zeph-subagent`/`zeph-core`: `SubAgentConfig` gains a tri-state
`delegation_mode` (`disabled` / `explicit_request_only` / `proactive`, spec
`042-subagent-delegation-mode-parity`, issue #5857), so an operator can keep sub-agents
enabled and useful while forbidding the main agent from autonomously deciding to spawn one —
e.g. in a Telegram/Discord/webhook-facing channel where prompt-injected input may reach the
agent. `explicit_request_only` permits only spawns attributable to a direct user action
(`/agent spawn`, `/agent resume`, `/subagent spawn`) and rejects the orchestration
scheduler's autonomous DAG dispatch; `disabled` rejects every spawn path (read-only
operations like `/agent list` still work); `proactive` (the default) preserves the
subsystem's prior unconstrained behavior. Enforcement is fail-closed: a new
`SpawnContext.origin: SpawnOrigin` field defaults to `Autonomous` (the restrictive value),
so an untagged spawn site is denied under the restrictive modes rather than silently
allowed — every real `.spawn()`/`.spawn_for_task()` call site was audited and explicitly
tagged. `SubAgentConfig.enabled` becomes the outer kill switch: `enabled = false` always
resolves to the `disabled` behavior regardless of `delegation_mode`'s configured value.
Rejected spawns return the new `SubAgentError::DelegationDenied` and log a distinguishable
`tracing::warn!`. Overridable via `ZEPH_AGENTS_DELEGATION_MODE` or `--delegation-mode`; the
`--init` wizard and `--migrate-config` (existing `[agents] enabled = true` configs gain an
explicit `delegation_mode = "proactive"`) are updated accordingly.

### Changed

- `zeph-skills`: `embed_skills_with_timeout` now tries `LlmProvider::embed_batch` first
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,11 @@ notify_ack_timeout_ms = 5000
[agents]
# Enable sub-agent spawning (required for /agent commands and multi-agent workflows)
enabled = false
# Whether the main agent may spawn sub-agents, and who may trigger it: "disabled" /
# "explicit_request_only" / "proactive". Orthogonal to `enabled` above, which remains the
# outer kill switch (enabled = false always resolves to the "disabled" behavior regardless
# of this value). Env override: ZEPH_AGENTS_DELEGATION_MODE. CLI override: --delegation-mode.
delegation_mode = "proactive"
# Maximum number of sub-agents that can run concurrently
max_concurrent = 1
# Allow sub-agents to use bypass_permissions mode (enable only in trusted environments)
Expand Down
160 changes: 160 additions & 0 deletions crates/zeph-config/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,61 @@ pub enum ContextInjectionMode {
Summary,
}

/// Tri-state control over whether the main agent may spawn sub-agents, and who may trigger it
/// (spec `042-subagent-delegation-mode-parity`, issue #5857).
///
/// Orthogonal to [`SubAgentConfig::enabled`], which remains the outer kill switch: when
/// `enabled = false`, the effective mode is always [`DelegationMode::Disabled`] regardless of
/// this field's value (FR-002). Also orthogonal to [`PermissionMode`] — that governs what a
/// spawned sub-agent may *do*; this governs whether a spawn may happen *at all* and who may
/// trigger it.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DelegationMode {
/// No spawn may proceed from any code path (slash command, orchestration planner/scheduler,
/// scheduled task). Read-only operations (`/agent list`, definition inspection, status
/// queries) remain available.
Disabled,
/// Only spawns attributable to a direct, explicit user action (e.g. `/agent spawn`) are
/// permitted; spawns originating from autonomous planner/scheduler decision-making are
/// rejected.
ExplicitRequestOnly,
/// Both explicit and autonomous spawn paths are permitted, subject to the pre-existing
/// constraints (`max_concurrent`, `max_spawn_depth`, permission grants, worktree isolation).
/// Matches the subsystem's behavior prior to this field's introduction.
#[default]
Proactive,
}

impl DelegationMode {
/// Whether a spawn/resume attempt attributable to a direct, explicit user action (e.g.
/// `/agent spawn`, `/agent resume`, `/subagent spawn`) is permitted under this mode.
///
/// Expressed as an allow-list (`Proactive` and `ExplicitRequestOnly` match; every other
/// value, including any future `#[non_exhaustive]` variant, does not) rather than a
/// deny-list (`self != Disabled`), so it fails closed automatically on a variant this
/// crate doesn't yet recognize instead of silently permitting it. Shared by every
/// origin-agnostic "is this explicit action even allowed at all" check — the ACP
/// `/subagent spawn` gate and `SubAgentManager::resume` — so the two enforcement points
/// cannot drift out of sync with each other. Does **not** cover `Autonomous`-origin spawns;
/// `SubAgentManager::spawn`'s own origin-aware gate handles that distinction directly.
///
/// # Examples
///
/// ```rust
/// use zeph_config::DelegationMode;
///
/// assert!(DelegationMode::Proactive.permits_explicit());
/// assert!(DelegationMode::ExplicitRequestOnly.permits_explicit());
/// assert!(!DelegationMode::Disabled.permits_explicit());
/// ```
#[must_use]
pub fn permits_explicit(self) -> bool {
matches!(self, Self::Proactive | Self::ExplicitRequestOnly)
}
}

fn default_max_parent_messages() -> usize {
20
}
Expand Down Expand Up @@ -542,6 +597,7 @@ impl Default for TaskSupervisorConfig {
/// ```toml
/// [agents]
/// enabled = true
/// delegation_mode = "explicit_request_only"
/// max_concurrent = 3
/// max_spawn_depth = 2
/// ```
Expand All @@ -550,7 +606,17 @@ impl Default for TaskSupervisorConfig {
#[allow(clippy::struct_excessive_bools)] // independent config toggles; bitflags or enum would obscure semantics without reducing complexity
pub struct SubAgentConfig {
/// Enable the sub-agent subsystem. Default: `false`.
///
/// Outer kill switch: when `false`, the effective [`delegation_mode`][Self::delegation_mode]
/// is always [`DelegationMode::Disabled`] regardless of that field's configured value
/// (spec `042-subagent-delegation-mode-parity` FR-002).
pub enabled: bool,
/// Whether the main agent may spawn sub-agents, and who may trigger it: `disabled` /
/// `explicit_request_only` / `proactive`. Default: [`DelegationMode::Proactive`] (preserves
/// the subsystem's unconstrained behavior prior to this field's introduction, per FR-008).
/// Overridable via `ZEPH_AGENTS_DELEGATION_MODE` or the `--delegation-mode` CLI flag.
#[serde(default)]
pub delegation_mode: DelegationMode,
/// Maximum number of sub-agents that can run concurrently.
#[serde(default = "default_max_concurrent")]
pub max_concurrent: usize,
Expand Down Expand Up @@ -650,6 +716,7 @@ impl Default for SubAgentConfig {
fn default() -> Self {
Self {
enabled: false,
delegation_mode: DelegationMode::default(),
max_concurrent: default_max_concurrent(),
extra_dirs: Vec::new(),
user_agents_dir: None,
Expand All @@ -674,6 +741,39 @@ impl Default for SubAgentConfig {
}
}

impl SubAgentConfig {
/// Resolve [`enabled`][Self::enabled] and [`delegation_mode`][Self::delegation_mode] into
/// the single effective mode that must be enforced at every spawn call site (spec
/// `042-subagent-delegation-mode-parity` FR-002, issue #5857).
///
/// `enabled` is the outer kill switch: `enabled = false` always resolves to
/// [`DelegationMode::Disabled`], regardless of the configured `delegation_mode` value.
///
/// # Examples
///
/// ```rust
/// use zeph_config::{DelegationMode, SubAgentConfig};
///
/// let mut cfg = SubAgentConfig {
/// enabled: false,
/// delegation_mode: DelegationMode::Proactive,
/// ..SubAgentConfig::default()
/// };
/// assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Disabled);
///
/// cfg.enabled = true;
/// assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Proactive);
/// ```
#[must_use]
pub fn effective_delegation_mode(&self) -> DelegationMode {
if self.enabled {
self.delegation_mode
} else {
DelegationMode::Disabled
}
}
}

/// Config-level lifecycle hooks fired when any sub-agent starts or stops.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
Expand Down Expand Up @@ -708,6 +808,66 @@ mod tests {
);
}

#[test]
fn subagent_config_delegation_mode_defaults_proactive() {
let cfg = SubAgentConfig::default();
assert_eq!(cfg.delegation_mode, DelegationMode::Proactive);
}

#[test]
fn subagent_config_deserialize_delegation_mode() {
let toml_str = "enabled = true\ndelegation_mode = \"explicit_request_only\"";
let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.delegation_mode, DelegationMode::ExplicitRequestOnly);
}

#[test]
fn subagent_config_delegation_mode_omitted_defaults_proactive() {
let toml_str = "enabled = true";
let cfg: SubAgentConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.delegation_mode, DelegationMode::Proactive);
}

#[test]
fn subagent_config_delegation_mode_rejects_unknown_value() {
let toml_str = "delegation_mode = \"sometimes\"";
let result: Result<SubAgentConfig, _> = toml::from_str(toml_str);
assert!(result.is_err(), "unrecognized value must fail to parse");
}

#[test]
fn permits_explicit_allow_list() {
assert!(DelegationMode::Proactive.permits_explicit());
assert!(DelegationMode::ExplicitRequestOnly.permits_explicit());
assert!(!DelegationMode::Disabled.permits_explicit());
}

#[test]
fn effective_delegation_mode_disabled_when_not_enabled() {
let cfg = SubAgentConfig {
enabled: false,
delegation_mode: DelegationMode::Proactive,
..SubAgentConfig::default()
};
assert_eq!(cfg.effective_delegation_mode(), DelegationMode::Disabled);
}

#[test]
fn effective_delegation_mode_passes_through_when_enabled() {
for mode in [
DelegationMode::Disabled,
DelegationMode::ExplicitRequestOnly,
DelegationMode::Proactive,
] {
let cfg = SubAgentConfig {
enabled: true,
delegation_mode: mode,
..SubAgentConfig::default()
};
assert_eq!(cfg.effective_delegation_mode(), mode);
}
}

#[test]
fn subagent_config_deserialize_forward_transcript() {
let toml_str = "forward_transcript = true";
Expand Down
14 changes: 14 additions & 0 deletions crates/zeph-config/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,20 @@ impl Config {
{
self.agents.forward_transcript = enabled;
}
if let Ok(v) = std::env::var("ZEPH_AGENTS_DELEGATION_MODE") {
match v.as_str() {
"disabled" => self.agents.delegation_mode = crate::DelegationMode::Disabled,
"explicit_request_only" => {
self.agents.delegation_mode = crate::DelegationMode::ExplicitRequestOnly;
}
"proactive" => self.agents.delegation_mode = crate::DelegationMode::Proactive,
other => tracing::warn!(
value = other,
"ZEPH_AGENTS_DELEGATION_MODE: invalid value, ignoring \
(expected disabled|explicit_request_only|proactive)"
),
}
}
}

fn apply_env_overrides_security(&mut self) {
Expand Down
5 changes: 3 additions & 2 deletions crates/zeph-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,9 @@ pub mod vigil;
pub mod worktree;

pub use agent::{
AgentConfig, ContextInjectionMode, FocusConfig, GoalConfig, ModelSpec, ParentContextPolicy,
SubAgentConfig, SubAgentLifecycleHooks, TaskSupervisorConfig, ToolFilterConfig,
AgentConfig, ContextInjectionMode, DelegationMode, FocusConfig, GoalConfig, ModelSpec,
ParentContextPolicy, SubAgentConfig, SubAgentLifecycleHooks, TaskSupervisorConfig,
ToolFilterConfig,
};
pub use channels::{
A2aClientConfig, A2aServerConfig, CardTrustPolicy, ChannelSkillsConfig, DiscordConfig,
Expand Down
41 changes: 23 additions & 18 deletions crates/zeph-config/src/migrate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod memory;
mod plugins;
mod serve;
mod session;
mod subagent;
mod tools;

pub use features::{
Expand All @@ -42,6 +43,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 tools::*;

/// Returns `true` when `name` is an active (non-commented) TOML section header in `src`.
Expand Down Expand Up @@ -605,24 +607,24 @@ mod steps;
use steps::{
MigrateA2aCardTrustConfig, MigrateA2aServerRemoveInertFields, MigrateAcpAuthClientsConfig,
MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
MigrateAgentTimeReminder, 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, MigrateMemoryGraph,
MigrateMemoryGraphRecallIncludeImported, MigrateMemoryHebbian,
MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig,
MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval,
MigrateMemoryRetrievalQueryBias, MigrateMemoryStoreConfig, MigrateMemoryTypeAwareCompose,
MigrateMicrocompactConfig, MigrateNliConfig, MigrateOrchestrationAssetSensitivity,
MigrateOrchestrationCommandConfig, MigrateOrchestrationEnsemble,
MigrateOrchestrationIdleTimeout, MigrateOrchestrationPersistence,
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, MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported,
MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread,
MigrateMemoryPersonaConfig, MigrateMemoryReasoning, MigrateMemoryReasoningJudge,
MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias, MigrateMemoryStoreConfig,
MigrateMemoryTypeAwareCompose, MigrateMicrocompactConfig, MigrateNliConfig,
MigrateOrchestrationAssetSensitivity, MigrateOrchestrationCommandConfig,
MigrateOrchestrationEnsemble, MigrateOrchestrationIdleTimeout, MigrateOrchestrationPersistence,
MigrateOrchestrationWholePlanVerifierTimeout, MigrateOrchestratorProvider, MigrateOtelFilter,
MigrateOverflowMaxPerCallOverride, MigratePiiFilterNames, MigratePlannerModelToProvider,
MigratePluginsReputationConfig, MigratePolicyProviderAndUtilityWindow,
Expand Down Expand Up @@ -847,6 +849,9 @@ pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>
Box::new(MigrateIntegrityConfig),
// Step 101 — advisory notice for [security.rate_limit] default-on posture (#6469)
Box::new(MigrateRateLimitAdvisory),
// Step 102 — insert active delegation_mode = "proactive" into an existing
// [agents] table with enabled = true and no delegation_mode key (#5857)
Box::new(MigrateAgentsDelegationMode),
]
});

Expand Down
Loading
Loading