Synced from monorepo

Synced from monorepo

Changes:
- Workspace server: report `/ready` as failed with dwell on hub connect failure
- Refresh OIDC token for the Grok agent in the shell
- ACP terminal output recorder
- Cross-platform provider auth commands in the shell
- Default `/resume` to Grok sessions with a hint for hidden external sessions
- Resume sessions by title with `--resume`
- Limit app-builder archive size
- Data-driven tag labels for slash commands
- Doctor fixes for tmux
- Custom provider gateways and subprocess environment policy in the shell
- `/tutorial` — opt-in onboarding tour of Grok Build
- Soft and required CLI version checks in the shell
- Privacy banner env overrides survive live settings updates
- Add remote flag to override the image-edit model
- Return profile fields from auth info even when the access token is expired
- Add edit control on queued prompt rows
- Keep fail-closed policy when clearing orphans with no team
- Setting to disable the Ctrl+Space/F8 voice shortcut
- Pass `--raw` to pw-record so Linux dictation works on older PipeWire
- Validate git URLs when adding marketplace entries
- Stop shipping stale tool-doc parameter and tool names
- Re-point dashboard attach after `/fork` only when the parent was attached
- Surface Grok Computer media-generation results as file-path chunks
- Clear web background-task tray on kill and keep the task description
- Show privacy upsell banner in agent view until acted on
- Add tools-server client callback surface
- Protect persistent global hook sources

Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
grokkybara[bot] 2026-07-23 17:12:33 +00:00
commit 69f0ba880a
286 changed files with 22939 additions and 9624 deletions

View file

@ -141,17 +141,22 @@ impl ToolBridge {
template: &str,
placeholders: &serde_json::Value,
) -> Option<String> {
let registry = &*self.registry;
let result;
{
result = registry
.resources
.lock()
.await
.get::<TemplateRenderer>()
.and_then(|r| r.render_with_extra(template, placeholders).ok());
}
result
self.registry
.resources
.lock()
.await
.get::<TemplateRenderer>()
.and_then(|renderer| renderer.render_with_extra(template, placeholders).ok())
}
/// Return the finalized template renderer for multi-part prompt assembly.
pub async fn template_renderer_snapshot(&self) -> Option<TemplateRenderer> {
self.registry
.resources
.lock()
.await
.get::<TemplateRenderer>()
.cloned()
}
pub async fn register_mcp_tools<T>(
@ -829,6 +834,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: owner.map(|s| s.to_string()),
description: None,
}
}

View file

@ -272,6 +272,7 @@ impl ShellState {
pub async fn init(
shell: ShellKind,
cwd: &Path,
shell_env_policy: Option<&crate::util::ShellEnvironmentPolicy>,
) -> Result<Self, crate::computer::types::ComputerError> {
let dump_script = shell.dump_script();
let dump_fn = shell.dump_function_name();
@ -297,6 +298,21 @@ impl ShellState {
.stderr(Stdio::null())
.kill_on_drop(true);
crate::util::detach_command(&mut cmd);
// Apply the policy before the `export -p` snapshot so the replayed state
// is already filtered; otherwise the restore would undo it. No-op unless set.
//
// SECURITY: this filters the base env only. Variables an rc file exports
// during login are captured in the replay snapshot and are not
// re-filtered by `exclude`/`include_only` on the persistent backend, so
// warn when a policy is active. The non-persistent backend has no such
// gap (it filters login capture directly).
if shell_env_policy.is_some_and(|p| !p.is_noop()) {
tracing::warn!(
"shell_environment_policy filters the persistent shell's base env only; \
variables exported by rc files enter the replay snapshot unfiltered"
);
}
crate::util::apply_shell_environment_policy(&mut cmd, shell_env_policy);
cmd.envs(crate::util::pager_env());
let mut child = cmd.spawn().map_err(|e| {
crate::computer::types::ComputerError::io(format!(
@ -839,7 +855,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
assert!(state.cwd.is_absolute());
// The snapshot should contain at least some env var exports
assert!(
@ -858,7 +874,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
// Run "export GROK_TEST_VAR=hello" and capture the new state
let prep = state
@ -964,7 +980,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
// cd to /tmp (macOS resolves to /private/tmp via symlink)
let (code, _) = run_command(&mut state, "cd /tmp").await;
@ -987,7 +1003,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
// Export a variable
let (code, _) = run_command(&mut state, "export MY_TEST_VAR=persistent_value").await;
@ -1006,7 +1022,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
let (code, _) = run_command(&mut state, "export GPG_TTY=/grok-sentinel-tty").await;
assert_eq!(code, 0);
@ -1026,7 +1042,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Zsh, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Zsh, &cwd, None).await.unwrap();
let (code, _) = run_command(&mut state, "export GPG_TTY=/grok-sentinel-tty").await;
assert_eq!(code, 0);
@ -1046,7 +1062,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Zsh, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Zsh, &cwd, None).await.unwrap();
let prep = state
.prepare_command(
@ -1091,7 +1107,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
// Define a function
let (code, _) = run_command(&mut state, "greet() { echo \"hello $1\"; }").await;
@ -1109,7 +1125,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
// Define an alias
let (code, _) = run_command(&mut state, "alias ll='ls -la'").await;
@ -1136,7 +1152,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
let prep = state.prepare_command("true", None, shadows, None).unwrap();
// Shadows enabled → the self-resolving find/grep functions are always
@ -1175,7 +1191,7 @@ mod tests {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd, None).await.unwrap();
// Set up some state
let (_, _) = run_command(&mut state, "export SURVIVE_TEST=yes").await;

View file

@ -303,6 +303,7 @@ struct ProcessState {
/// Session that owns this process. Used to scope kill operations so
/// subagent teardown only kills the subagent's own tasks.
owner_session_id: Option<String>,
description: Option<String>,
}
impl ProcessState {
@ -442,6 +443,7 @@ impl ProcessState {
explicitly_killed: self.explicitly_killed,
kind: self.kind,
owner_session_id: self.owner_session_id.clone(),
description: self.description.clone(),
}
}
}
@ -516,6 +518,10 @@ struct LocalTerminalActor {
/// a subagent reusing this backend can't clobber the parent's shadows.
search_shadows: SearchShadowConfig,
/// Shell-environment policy baked in at construction (like `search_shadows`);
/// `None` inherits the full environment.
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
/// Persistent shell state (env vars, cwd, functions, aliases).
/// Lazily initialized on first command when `persistent_shell` is true.
#[cfg(unix)]
@ -542,11 +548,13 @@ impl LocalTerminalActor {
foreground_block_budget: Duration,
output_file_cap: u64,
scope: crate::util::ProcessScope,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
) -> Self {
Self {
cmd_rx,
cancel_token,
scope,
shell_env_policy,
processes: HashMap::new(),
completion_waiters: HashMap::new(),
completed_task_snapshots: HashMap::new(),
@ -598,8 +606,14 @@ impl LocalTerminalActor {
#[cfg(not(unix))]
let login_env: Option<&HashMap<String, String>> = None;
let (child, process_group) =
spawn_shell_command(command, cwd, env, login_env, self.search_shadows)?;
let (child, process_group) = spawn_shell_command(
command,
cwd,
env,
login_env,
self.search_shadows,
self.shell_env_policy.as_ref(),
)?;
Ok(SpawnResult {
child,
process_group,
@ -658,22 +672,12 @@ impl LocalTerminalActor {
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(login) = self.login_env.as_ref() {
for (key, value) in login {
if key != "PATH" && std::env::var_os(key).is_none() {
cmd.env(key, value);
}
}
}
cmd.envs(shell_state::shell_env_overrides());
for (key, value) in env {
cmd.env(key, value);
}
cmd.envs(crate::util::pager_env());
if let Some(path) = self.login_env.as_ref().and_then(|l| l.get("PATH")) {
cmd.env("PATH", path);
}
crate::util::apply_grok_agent_marker(&mut cmd);
apply_child_env(
&mut cmd,
self.shell_env_policy.as_ref(),
self.login_env.as_ref(),
env,
);
cmd.fd_mappings(prep.fd_mappings)
.map_err(|e| ComputerError::io(format!("fd mapping: {e}")))?;
@ -722,7 +726,7 @@ impl LocalTerminalActor {
return;
}
let shell = shell_state::ShellKind::detect();
match shell_state::ShellState::init(shell, cwd).await {
match shell_state::ShellState::init(shell, cwd, self.shell_env_policy.as_ref()).await {
Ok(state) => self.shell_state = Some(state),
Err(e) => {
tracing::warn!("persistent shell init failed, using empty state: {e}");
@ -791,17 +795,9 @@ impl LocalTerminalActor {
.stderr(Stdio::piped())
.kill_on_drop(true);
// Apply SHELL_ENV_OVERRIDES (TERM=dumb, NO_COLOR, GROK_AGENT=1, etc.)
// + request env + pager env. Agent marker is re-applied last so request
// env cannot clear it.
cmd.envs(shell_state::shell_env_overrides());
for (key, value) in env {
cmd.env(key, value);
}
cmd.envs(crate::util::pager_env());
crate::util::apply_grok_agent_marker(&mut cmd);
// The persistent backend restores login state from its snapshot, so no
// login-env layering here.
apply_child_env(&mut cmd, self.shell_env_policy.as_ref(), None, env);
cmd.fd_mappings(prep.fd_mappings)
.map_err(|e| ComputerError::io(format!("fd mapping: {e}")))?;
@ -1095,6 +1091,7 @@ impl LocalTerminalActor {
explicitly_killed: false,
state_dump_handle,
owner_session_id: request.owner_session_id.clone(),
description: request.description.filter(|d| !d.trim().is_empty()),
};
// Send an initial empty notification so the TUI shows the execution
@ -1238,6 +1235,7 @@ impl LocalTerminalActor {
None
},
owner_session_id: request.owner_session_id.clone(),
description: request.description.filter(|d| !d.trim().is_empty()),
};
// Store under task_id — this is the key that get_task/kill_task will use
@ -1618,6 +1616,7 @@ impl LocalTerminalActor {
block_waited: p.block_waited,
explicitly_killed: p.explicitly_killed,
owner_session_id: p.owner_session_id.clone(),
description: p.description.clone(),
};
self.completed_task_snapshots.insert(id.clone(), snapshot);
}
@ -2044,15 +2043,25 @@ impl LocalTerminalActor {
// "Monitor" row (matching the original-spawn path) rather than a
// bash-highlighted "[monitor] …".
let is_monitor = process.kind == crate::computer::types::TaskKind::Monitor;
let monitor_description = if is_monitor {
// Recover monitor label once; reuse for backgrounded notify + pipeline.
// Filter empty/whitespace the same way as spawn so `[monitor] `
// / blank recovery does not stick as Some("") and block the
// command fallback for the re-spawned pipeline label.
let recovered_monitor_description = if is_monitor {
process
.display_command
.as_deref()
.and_then(|d| d.strip_prefix("[monitor] "))
.map(str::to_string)
.filter(|d| !d.trim().is_empty())
} else {
None
};
let effective_description = process
.description
.clone()
.filter(|d| !d.trim().is_empty())
.or_else(|| recovered_monitor_description.clone());
let reparent_command = if is_monitor {
process.command.clone()
} else {
@ -2072,20 +2081,16 @@ impl LocalTerminalActor {
},
output_file: process.output_file.clone(),
task_id: task_id.clone(),
monitor_description,
// Reparent path has no model tool description; monitors use
// `monitor_description` above.
description: None,
monitor_description: recovered_monitor_description,
description: effective_description.clone(),
});
// Re-spawn the monitor pipeline so events continue streaming.
// The old pipeline died with the child's runtime.
if process.kind == crate::computer::types::TaskKind::Monitor {
let pipeline_task_id = task_id.clone();
let pipeline_description = process
.display_command
.clone()
.unwrap_or_else(|| process.command.clone());
let pipeline_description =
effective_description.unwrap_or_else(|| process.command.clone());
// Weak so the reparented monitor doesn't pin the backend.
let pipeline_terminal = backend_weak.clone();
let pipeline_notif = new_handle.clone();
@ -2127,6 +2132,31 @@ pub struct LocalTerminalBackend {
cancel_token: CancellationToken,
}
/// Grouped inputs for [`LocalTerminalBackend::new_inner`], so call sites read as
/// named fields instead of a telescoping list of positional `bool`s. Constructors
/// override only the fields they vary via `..Default::default()`.
struct LocalTerminalConfig {
memory_config: Option<CgroupMemoryConfig>,
use_spawn_local: bool,
persistent_shell: bool,
login_shell_capture: bool,
search_shadows: SearchShadowConfig,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
}
impl Default for LocalTerminalConfig {
fn default() -> Self {
Self {
memory_config: None,
use_spawn_local: false,
persistent_shell: false,
login_shell_capture: true,
search_shadows: SearchShadowConfig::default(),
shell_env_policy: None,
}
}
}
impl LocalTerminalBackend {
/// Create a new LocalTerminalBackend and spawn the actor task.
///
@ -2134,7 +2164,7 @@ impl LocalTerminalBackend {
/// If `memory_config` is provided, a cgroupv2 memory limit is enforced on
/// all spawned commands (Linux only; silently degrades to no-op elsewhere).
pub fn new() -> Self {
Self::new_inner(None, false, false, true, SearchShadowConfig::default())
Self::new_inner(LocalTerminalConfig::default())
}
/// Create a new LocalTerminalBackend with persistent shell state.
@ -2143,31 +2173,29 @@ impl LocalTerminalBackend {
/// and shell options persist across command invocations. The user's login shell
/// (bash or zsh) is detected and its rc files are loaded once on first command.
pub fn with_persistent_shell() -> Self {
Self::new_inner(None, false, true, true, SearchShadowConfig::default())
Self::new_inner(LocalTerminalConfig {
persistent_shell: true,
..Default::default()
})
}
/// Create a new LocalTerminalBackend with cgroup memory limits.
///
/// See [`CgroupMemoryConfig`] for details on the soft/hard limit model.
pub fn with_memory_limit(config: CgroupMemoryConfig) -> Self {
Self::new_inner(
Some(config),
false,
false,
true,
SearchShadowConfig::default(),
)
Self::new_inner(LocalTerminalConfig {
memory_config: Some(config),
..Default::default()
})
}
/// Create a new LocalTerminalBackend with both memory limits and persistent shell.
pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self {
Self::new_inner(
Some(config),
false,
true,
true,
SearchShadowConfig::default(),
)
Self::new_inner(LocalTerminalConfig {
memory_config: Some(config),
persistent_shell: true,
..Default::default()
})
}
/// Create a new LocalTerminalBackend using spawn_local (for single-threaded runtimes).
@ -2175,33 +2203,51 @@ impl LocalTerminalBackend {
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
/// state, baked into this backend (see [`SearchShadowConfig`]).
pub fn new_local(search_shadows: SearchShadowConfig) -> Self {
Self::new_inner(None, true, false, true, search_shadows)
Self::new_inner(LocalTerminalConfig {
use_spawn_local: true,
search_shadows,
..Default::default()
})
}
pub fn new_local_with_login_shell_capture(
search_shadows: SearchShadowConfig,
login_shell_capture: bool,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
) -> Self {
Self::new_inner(None, true, false, login_shell_capture, search_shadows)
Self::new_inner(LocalTerminalConfig {
use_spawn_local: true,
login_shell_capture,
search_shadows,
shell_env_policy,
..Default::default()
})
}
/// Create a new LocalTerminalBackend using spawn_local with persistent shell.
///
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
/// state, baked into this backend (see [`SearchShadowConfig`]).
pub fn new_local_with_persistent_shell(search_shadows: SearchShadowConfig) -> Self {
Self::new_inner(None, true, true, true, search_shadows)
pub fn new_local_with_persistent_shell(
search_shadows: SearchShadowConfig,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
) -> Self {
Self::new_inner(LocalTerminalConfig {
use_spawn_local: true,
persistent_shell: true,
search_shadows,
shell_env_policy,
..Default::default()
})
}
/// Create a new LocalTerminalBackend using spawn_local with memory limits.
pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self {
Self::new_inner(
Some(config),
true,
false,
true,
SearchShadowConfig::default(),
)
Self::new_inner(LocalTerminalConfig {
memory_config: Some(config),
use_spawn_local: true,
..Default::default()
})
}
/// Test-only: a spawn_local backend that enrolls spawned children into
@ -2222,6 +2268,7 @@ impl LocalTerminalBackend {
FOREGROUND_BLOCK_BUDGET,
MAX_OUTPUT_FILE_BYTES,
scope,
None,
)
}
@ -2238,6 +2285,7 @@ impl LocalTerminalBackend {
FOREGROUND_BLOCK_BUDGET,
MAX_OUTPUT_FILE_BYTES,
crate::util::global_process_scope().clone(),
None,
)
}
@ -2254,6 +2302,7 @@ impl LocalTerminalBackend {
budget,
MAX_OUTPUT_FILE_BYTES,
crate::util::global_process_scope().clone(),
None,
)
}
@ -2270,16 +2319,19 @@ impl LocalTerminalBackend {
FOREGROUND_BLOCK_BUDGET,
output_file_cap,
crate::util::global_process_scope().clone(),
None,
)
}
fn new_inner(
memory_config: Option<CgroupMemoryConfig>,
use_spawn_local: bool,
persistent_shell: bool,
login_shell_capture: bool,
search_shadows: SearchShadowConfig,
) -> Self {
fn new_inner(config: LocalTerminalConfig) -> Self {
let LocalTerminalConfig {
memory_config,
use_spawn_local,
persistent_shell,
login_shell_capture,
search_shadows,
shell_env_policy,
} = config;
Self::new_with_ttl(
memory_config,
use_spawn_local,
@ -2290,6 +2342,7 @@ impl LocalTerminalBackend {
foreground_block_budget_from_env(),
output_file_cap_from_env(),
crate::util::global_process_scope().clone(),
shell_env_policy,
)
}
@ -2303,6 +2356,7 @@ impl LocalTerminalBackend {
foreground_block_budget: Duration,
output_file_cap: u64,
scope: crate::util::ProcessScope,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_CHANNEL_SIZE);
let cancel_token = CancellationToken::new();
@ -2329,6 +2383,7 @@ impl LocalTerminalBackend {
foreground_block_budget,
output_file_cap,
scope,
shell_env_policy,
);
actor.run().await;
};
@ -2920,19 +2975,107 @@ async fn capture_login_env() -> HashMap<String, String> {
}
}
/// Spawn the shell command and attach the child to a [`ProcessGroup`].
/// Layer login-shell captured vars (except `PATH`) onto `cmd`, dropping those the
/// active policy filters out and those already set in grok's own environment.
#[cfg(unix)]
fn layer_login_env_vars(
cmd: &mut tokio::process::Command,
login_env: Option<&HashMap<String, String>>,
active_policy: Option<&crate::util::ShellEnvironmentPolicy>,
) {
if let Some(login) = login_env {
for (key, value) in login {
// `var_os` reads grok's own process env (not the possibly cleared
// child env): a login var already present in grok's environment is
// left alone. Capture is filtered through the policy so an rc export
// cannot bypass it.
if key != "PATH"
&& std::env::var_os(key).is_none()
&& active_policy.is_none_or(|p| p.allows_with_inherit(key))
{
cmd.env(key, value);
}
}
}
}
/// Layer per-request env (`.envrc`, ACP, session settings) onto `cmd`, dropping
/// names the active policy excludes so a request-supplied secret cannot bypass
/// it. Honors `exclude`/`include_only`/default excludes, not `inherit`, since
/// request env is provided explicitly rather than inherited.
fn layer_request_env(
cmd: &mut tokio::process::Command,
env: &HashMap<String, String>,
active_policy: Option<&crate::util::ShellEnvironmentPolicy>,
) {
for (key, value) in env {
if active_policy.is_none_or(|p| p.allows(key)) {
cmd.env(key, value);
}
}
}
/// Re-inject the login-shell `PATH` last (so rc-file additions win), unless the
/// active policy filters `PATH` out.
#[cfg(unix)]
fn layer_login_path(
cmd: &mut tokio::process::Command,
login_env: Option<&HashMap<String, String>>,
active_policy: Option<&crate::util::ShellEnvironmentPolicy>,
) {
if let Some(path) = login_env.and_then(|l| l.get("PATH"))
&& active_policy.is_none_or(|p| p.allows_with_inherit("PATH"))
{
cmd.env("PATH", path);
}
}
/// Compose the child environment on `cmd` in one place, in a fixed order:
/// policy base, login-shell capture, grok control vars, request env, pager
/// vars, login `PATH` last, then the agent marker. Untrusted layers (login
/// capture and request env) pass through the policy name filter so an excluded
/// name cannot re-enter; grok's own control vars, login `PATH`, and the marker
/// are applied unfiltered and last. `login_env` is `None` for the persistent
/// backend, which restores login state from its own snapshot.
///
/// The returned `ProcessGroup` is what the teardown helpers
/// ([`send_sigterm_to_group`], [`send_sigkill_to_group`]) dispatch to:
/// `killpg` on Unix; `TerminateJobObject` on Windows. This gives
/// grandchild teardown for fan-out workloads (npm install, git clone,
/// cargo build) on both platforms.
/// Layers are applied incrementally rather than composed into one map and
/// installed via `env_clear`: the default policy is a no-op, and the common
/// path must inherit grok's environment untouched (including non-UTF-8 vars).
/// A base env is cleared and rebuilt only when a policy is active. Request env
/// is filtered by name only, so `inherit = none` still admits explicitly
/// provided `.envrc`/ACP vars.
///
/// Unix only: the Windows spawn path applies the policy inline (it has no
/// login-shell capture and uses the shell-invocation env instead of overrides).
#[cfg(unix)]
fn apply_child_env(
cmd: &mut tokio::process::Command,
policy: Option<&crate::util::ShellEnvironmentPolicy>,
login_env: Option<&HashMap<String, String>>,
request_env: &HashMap<String, String>,
) {
let active_policy = policy.filter(|p| !p.is_noop());
// 1. Base env: cleared and rebuilt from the policy only when one is active.
crate::util::shell_env_policy::install_policy_base_env(cmd, active_policy);
// 2. Login-shell capture (filtered). 3. Grok control vars. 4. Request env
// (filtered). 5. Pager vars. 6. Login PATH last. 7. Agent marker wins.
layer_login_env_vars(cmd, login_env, active_policy);
cmd.envs(shell_state::shell_env_overrides());
layer_request_env(cmd, request_env, active_policy);
cmd.envs(crate::util::pager_env());
layer_login_path(cmd, login_env, active_policy);
crate::util::apply_grok_agent_marker(cmd);
}
/// Spawn the shell command and attach the child to a [`ProcessGroup`] for
/// grandchild teardown (`killpg` on Unix, `TerminateJobObject` on Windows).
fn spawn_shell_command(
command: &str,
cwd: &std::path::Path,
env: &HashMap<String, String>,
login_env: Option<&HashMap<String, String>>,
search_shadows: SearchShadowConfig,
shell_env_policy: Option<&crate::util::ShellEnvironmentPolicy>,
) -> std::io::Result<(tokio::process::Child, crate::util::ProcessGroup)> {
// `login_env` and `search_shadows` are only consumed by the `#[cfg(unix)]`
// shell wrapper below; keep them live on Windows to avoid unused-arg warnings.
@ -2966,30 +3109,7 @@ fn spawn_shell_command(
// detach_from_tty() handles both session and process group creation.
.kill_on_drop(true);
if let Some(login) = login_env {
for (key, value) in login {
if key != "PATH" && std::env::var_os(key).is_none() {
cmd.env(key, value);
}
}
}
// Apply env vars from the request (e.g., .envrc, color vars, ACP-provided vars).
cmd.envs(shell_state::shell_env_overrides());
for (key, value) in env {
cmd.env(key, value);
}
cmd.envs(crate::util::pager_env());
// Inject the user's login-shell PATH LAST so tools installed via rc
// files (.bashrc, .zshrc, virtualenvs) are always discoverable. The
// request env often carries a copy of the parent process's PATH which
// doesn't include rc-file additions — applying login PATH after the
// request env ensures those additions aren't clobbered.
if let Some(path) = login_env.and_then(|l| l.get("PATH")) {
cmd.env("PATH", path);
}
// Agent marker must win over request/login env.
crate::util::apply_grok_agent_marker(&mut cmd);
apply_child_env(&mut cmd, shell_env_policy, login_env, env);
// Detach from the controlling terminal so subprocesses cannot open
// /dev/tty and compete with the TUI for terminal input.
@ -3018,18 +3138,21 @@ fn spawn_shell_command(
let inv = xai_grok_config::shell::shell_command_argv(command);
let mut cmd = tokio::process::Command::new(&inv.program);
cmd.args(&inv.args)
.envs(inv.env)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
for (key, value) in env {
cmd.env(key, value);
}
// Policy base first (cleared + rebuilt only when a policy is active), then
// the shell-invocation env, the filtered request env, pager vars, and the
// agent marker last. Mirrors the unix ordering in `apply_child_env`;
// `inv.env` is grok's trusted shell setup, so it is not filtered.
let active_policy = shell_env_policy.filter(|p| !p.is_noop());
crate::util::shell_env_policy::install_policy_base_env(&mut cmd, active_policy);
cmd.envs(inv.env);
layer_request_env(&mut cmd, env, active_policy);
cmd.envs(crate::util::pager_env());
// Agent marker must win over request env.
crate::util::apply_grok_agent_marker(&mut cmd);
// Set creation flags inline rather than via crate::util::detach_command
@ -3142,9 +3265,42 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
}
}
#[tokio::test]
async fn run_background_preserves_description_on_snapshot() {
let backend = LocalTerminalBackend::new();
let mut with_desc = make_request("sleep 30");
with_desc.description = Some("build frontend".to_string());
let handle = backend.run_background(with_desc).await.unwrap();
let snap = backend
.get_task(&handle.task_id)
.await
.expect("running task snapshot");
assert_eq!(snap.description.as_deref(), Some("build frontend"));
let listed = backend.list_tasks().await;
let listed_snap = listed
.iter()
.find(|t| t.task_id == handle.task_id)
.expect("task listed");
assert_eq!(listed_snap.description.as_deref(), Some("build frontend"));
let _ = backend.kill_task(&handle.task_id).await;
let without = make_request("sleep 30");
let handle = backend.run_background(without).await.unwrap();
let snap = backend
.get_task(&handle.task_id)
.await
.expect("running task snapshot");
assert!(
snap.description.is_none(),
"absent description must stay None"
);
let _ = backend.kill_task(&handle.task_id).await;
}
/// Poll `get_task` every 25ms until the task reports `completed`, returning
/// `false` if `timeout` elapses first. Lets callers keep a bespoke assert
/// message while sharing the poll-until-reaped boilerplate.
@ -3170,6 +3326,79 @@ mod tests {
}
}
#[test]
fn layer_request_env_drops_names_the_policy_excludes() {
use crate::util::{EnvironmentVariablePattern, ShellEnvironmentPolicy};
let glob = EnvironmentVariablePattern::new_case_insensitive;
let policy = ShellEnvironmentPolicy {
exclude: vec![glob("AWS_*")],
include_only: vec![glob("PATH"), glob("SAFE_*")],
..Default::default()
};
let env = HashMap::from([
("PATH".to_string(), "/bin".to_string()),
("SAFE_FLAG".to_string(), "1".to_string()),
("AWS_SECRET".to_string(), "leak".to_string()),
("OTHER".to_string(), "x".to_string()),
]);
let mut cmd = tokio::process::Command::new("true");
layer_request_env(&mut cmd, &env, Some(&policy));
let applied: HashMap<String, String> = cmd
.as_std()
.get_envs()
.filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
.collect();
assert_eq!(applied.get("PATH").map(String::as_str), Some("/bin"));
assert_eq!(applied.get("SAFE_FLAG").map(String::as_str), Some("1"));
assert!(!applied.contains_key("AWS_SECRET"));
assert!(!applied.contains_key("OTHER"));
}
#[cfg(unix)]
#[test]
fn apply_child_env_layers_in_fixed_order() {
use crate::util::{EnvironmentVariablePattern, ShellEnvironmentPolicy};
let policy = ShellEnvironmentPolicy {
exclude: vec![EnvironmentVariablePattern::new_case_insensitive("*SECRET*")],
set: HashMap::from([("GROK_TEST_BASE".to_string(), "1".to_string())]),
..Default::default()
};
let login = HashMap::from([
("GROK_TEST_LOGIN".to_string(), "l".to_string()),
("PATH".to_string(), "/login/bin".to_string()),
]);
let request = HashMap::from([
("GROK_TEST_REQ".to_string(), "r".to_string()),
("PATH".to_string(), "/req/bin".to_string()),
("GROK_TEST_SECRET".to_string(), "s".to_string()),
]);
let mut cmd = tokio::process::Command::new("true");
apply_child_env(&mut cmd, Some(&policy), Some(&login), &request);
let env: HashMap<String, String> = cmd
.as_std()
.get_envs()
.filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
.collect();
assert_eq!(env.get("GROK_TEST_BASE").map(String::as_str), Some("1"));
assert_eq!(env.get("GROK_TEST_LOGIN").map(String::as_str), Some("l"));
assert_eq!(env.get("GROK_TEST_REQ").map(String::as_str), Some("r"));
// Request env is filtered by the policy.
assert!(!env.contains_key("GROK_TEST_SECRET"));
// Login PATH is applied last and wins over the request PATH.
assert_eq!(env.get("PATH").map(String::as_str), Some("/login/bin"));
// The agent marker wins over every layer.
assert_eq!(
env.get(crate::util::GROK_AGENT_ENV).map(String::as_str),
Some(crate::util::GROK_AGENT_ENV_VALUE)
);
}
#[tokio::test]
#[ignore = "flaky: combined_output is sometimes empty in CI"]
async fn test_simple_command() {
@ -3220,6 +3449,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3251,6 +3481,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3314,6 +3545,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let start = Instant::now();
@ -3385,6 +3617,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3431,6 +3664,7 @@ mod tests {
foreground_block_budget: Some(Duration::from_millis(300)),
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let start = Instant::now();
@ -3482,6 +3716,7 @@ mod tests {
foreground_block_budget: Some(Duration::MAX),
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let start = Instant::now();
@ -3533,6 +3768,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3591,6 +3827,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3627,6 +3864,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
// Start background task
@ -3667,6 +3905,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let handle = backend.run_background(request).await.unwrap();
@ -3707,6 +3946,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3782,6 +4022,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3848,6 +4089,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3883,6 +4125,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3917,6 +4160,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -3947,6 +4191,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
backend.run(request).await.unwrap();
@ -3986,6 +4231,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
backend.run(request).await.unwrap();
@ -4034,6 +4280,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -4067,6 +4314,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let handle = backend.run_background(request).await.unwrap();
@ -4111,6 +4359,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let result = backend.run(request).await.unwrap();
@ -4143,6 +4392,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Bash,
owner_session_id: None,
description: None,
};
let start = Instant::now();

View file

@ -110,6 +110,8 @@ pub struct TerminalRunRequest {
/// `kill_all_background_tasks_by_owner` only targets the requesting
/// session's processes — not the parent's or sibling's.
pub owner_session_id: Option<String>,
/// Model-supplied label for task UI / snapshots.
pub description: Option<String>,
}
/// Distinguishes different types of background tasks.
@ -214,6 +216,9 @@ pub struct TaskSnapshot {
/// the parent's or sibling's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_session_id: Option<String>,
/// Model-supplied label for task UI / snapshots.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl TaskSnapshot {

View file

@ -2037,6 +2037,7 @@ impl xai_tool_runtime::Tool for BashTool {
foreground_block_budget: None,
kind: crate::computer::types::TaskKind::Bash,
owner_session_id: owner_session_id.clone(),
description: Some(input.description.clone()).filter(|d| !d.trim().is_empty()),
};
let handle = match backend.run_background(request).await {
@ -2072,7 +2073,7 @@ impl xai_tool_runtime::Tool for BashTool {
output_file: bg_output_file.clone(),
task_id: task_id.clone(),
monitor_description: None,
description: Some(input.description.clone()),
description: Some(input.description.clone()).filter(|d| !d.trim().is_empty()),
});
let retrieval_hint = Self::background_retrieval_hint(&resources, &task_id).await?;
@ -2133,6 +2134,7 @@ impl xai_tool_runtime::Tool for BashTool {
foreground_block_budget: Self::effective_foreground_block_budget(&params),
kind: crate::computer::types::TaskKind::Bash,
owner_session_id: owner_session_id.clone(),
description: Some(input.description.clone()).filter(|d| !d.trim().is_empty()),
};
let result = match backend.run(request).await {
@ -2166,7 +2168,7 @@ impl xai_tool_runtime::Tool for BashTool {
output_file: output_file.clone(),
task_id: tool_call_id.as_str().to_owned(),
monitor_description: None,
description: Some(input.description.clone()),
description: Some(input.description.clone()).filter(|d| !d.trim().is_empty()),
});
let retrieval_hint =
@ -2233,7 +2235,7 @@ impl xai_tool_runtime::Tool for BashTool {
truncated: result.truncated,
signal: result.signal,
timed_out: result.timed_out,
description: Some(input.description),
description: Some(input.description).filter(|d| !d.trim().is_empty()),
current_dir: cwd.to_string_lossy().to_string(),
output_file: output_file.to_string_lossy().to_string(),
total_bytes: result.total_bytes,

View file

@ -26,7 +26,7 @@ use crate::types::resources::SessionFolder;
use crate::types::tool::{ToolKind, ToolNamespace};
use crate::util::image_compress::{FilterType, ReEncodeParams, re_encode_under_limit};
const XAI_IMAGINE_MODEL: &str = "grok-imagine-image-quality";
pub(crate) const XAI_IMAGINE_EDIT_MODEL: &str = "grok-imagine-image-quality";
/// Size/dimension limits for reference images sent to the Imagine API.
/// Tighter than the vision path; the backend returns 400 when exceeded.
@ -353,7 +353,7 @@ impl xai_tool_runtime::Tool for ImageEditTool {
let url = format!("{base}/images/edits");
let mut payload = serde_json::json!({
"model": XAI_IMAGINE_MODEL,
"model": client.edit_model(),
"prompt": input.prompt,
"n": 1,
"resolution": "1k",

View file

@ -58,6 +58,7 @@ pub struct ImageGenClient {
/// [`XAI_IMAGINE_MODEL`]). `image_edit` uses its own model and is
/// unaffected.
model: String,
edit_model: String,
writer: super::storage::SessionFileWriter,
api_key_provider: Option<SharedApiKeyProvider>,
/// Optional 401-attribution hook. Hosts wire this so a 401 from the
@ -81,6 +82,7 @@ impl ImageGenClient {
base_url,
extra_headers,
model_override,
edit_model_override,
tier_restricted,
..
} = config
@ -93,6 +95,10 @@ impl ImageGenClient {
.clone()
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| XAI_IMAGINE_MODEL.to_owned());
let edit_model = edit_model_override
.clone()
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| super::image_edit::XAI_IMAGINE_EDIT_MODEL.to_owned());
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
@ -138,6 +144,7 @@ impl ImageGenClient {
http,
base_url: base_url.clone(),
model,
edit_model,
writer: super::storage::SessionFileWriter::new(DEFAULT_IMAGE_DIR, "jpg"),
api_key_provider,
attribution_callback: None,
@ -183,6 +190,10 @@ impl ImageGenClient {
&self.writer
}
pub(crate) fn edit_model(&self) -> &str {
&self.edit_model
}
pub async fn generate(
&self,
prompt: &str,
@ -277,6 +288,7 @@ pub enum ImageGenConfig {
/// ([`XAI_IMAGINE_MODEL`]). Driven by the remote
/// `image_gen_model_override` config flag. `image_edit` is unaffected.
model_override: Option<String>,
edit_model_override: Option<String>,
/// `true` when the user is on a tier the Imagine server zero-limits
/// (free / X Basic). The tools stay advertised to the model, but
/// `image_gen` / `image_edit` short-circuit at call time with the
@ -483,6 +495,7 @@ mod tests {
image_gen_enabled: false,
image_edit_enabled: true,
model_override: Some("grok-imagine-image".into()),
edit_model_override: None,
tier_restricted: false,
};
assert!(cfg.has_credentials());
@ -502,6 +515,7 @@ mod tests {
image_gen_enabled: true,
image_edit_enabled: true,
model_override: model_override.map(String::from),
edit_model_override: None,
tier_restricted: false,
};
// No override → default quality model.
@ -523,6 +537,33 @@ mod tests {
);
}
#[test]
fn client_selects_edit_model_from_override() {
let mk = |edit_model_override: Option<&str>| ImageGenConfig::Enabled {
api_key: "k".into(),
base_url: "https://api.x.ai/v1".into(),
extra_headers: indexmap::IndexMap::new(),
image_gen_enabled: true,
image_edit_enabled: true,
model_override: None,
edit_model_override: edit_model_override.map(String::from),
tier_restricted: false,
};
assert_eq!(
ImageGenClient::new(&mk(None), None).unwrap().edit_model(),
super::super::image_edit::XAI_IMAGINE_EDIT_MODEL
);
assert_eq!(
ImageGenClient::new(&mk(Some(" ")), None)
.unwrap()
.edit_model(),
super::super::image_edit::XAI_IMAGINE_EDIT_MODEL
);
let client = ImageGenClient::new(&mk(Some("grok-imagine-image-v2")), None).unwrap();
assert_eq!(client.edit_model(), "grok-imagine-image-v2");
assert_eq!(client.model, XAI_IMAGINE_MODEL);
}
#[tokio::test]
async fn errors_when_client_missing() {
let tool = ImageGenTool;
@ -558,6 +599,7 @@ mod tests {
image_gen_enabled: true,
image_edit_enabled: true,
model_override: None,
edit_model_override: None,
tier_restricted: true,
};
let mut resources = crate::types::resources::Resources::new();

View file

@ -83,7 +83,7 @@ impl xai_tool_runtime::Tool for MonitorTool {
.map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))?;
let resolved_timeout = input.resolved_timeout_ms();
let description = input.description.clone();
let description = input.description;
let (terminal, notification_handle, cwd, session_folder, owner_session_id) = {
let res = resources.lock().await;
@ -127,16 +127,18 @@ impl xai_tool_runtime::Tool for MonitorTool {
output_file,
notification_handle: notification_handle.clone(),
tool_call_id: ctx.call_id.as_str().to_owned(),
display_command: Some(format!("[monitor] {}", input.description)),
display_command: Some(format!("[monitor] {description}")),
auto_background_on_timeout: false,
foreground_block_budget: None,
kind: crate::computer::types::TaskKind::Monitor,
owner_session_id,
description: Some(description.clone()).filter(|d| !d.trim().is_empty()),
})
.await
.map_err(|e| xai_tool_runtime::ToolError::custom("process_manager", e.to_string()))?;
let task_id = bg_handle.task_id.clone();
let tray_description = Some(description.clone()).filter(|d| !d.trim().is_empty());
// Notify the pager so the monitor appears in the tasks pane
// (same notification that bash background tasks send).
@ -155,15 +157,15 @@ impl xai_tool_runtime::Tool for MonitorTool {
},
output_file: bg_handle.output_file.clone(),
task_id: task_id.clone(),
monitor_description: Some(input.description.clone()),
description: None,
monitor_description: tray_description.clone(),
description: tray_description,
});
// Spawn the stdout processing pipeline.
// Reads the output file, processes lines through the rate limiter,
// and emits MonitorEvent notifications.
let pipeline_task_id = task_id.clone();
let pipeline_description = description.clone();
let pipeline_description = description;
// Weak handle: the pipeline must not keep the session's terminal backend
// (and the monitored process) alive past session end. See
// `run_monitor_pipeline`.
@ -449,6 +451,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Monitor,
owner_session_id: Some("session-A".to_string()),
description: None,
})
.await
.expect("spawn monitor");
@ -525,6 +528,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Monitor,
owner_session_id: Some("session-A".to_string()),
description: None,
})
.await
.expect("spawn monitor");
@ -594,6 +598,7 @@ mod tests {
foreground_block_budget: None,
kind: TaskKind::Monitor,
owner_session_id: Some("child-session".to_string()),
description: None,
})
.await
.expect("spawn monitor");

View file

@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken;
use crate::implementations::grok_build::task::types::{
SessionIdResource, SubagentEvent, SubagentEventSender, SubagentLoopUnitActiveRequest,
SubagentOwner, SubagentQueryRequest, SubagentRequest, SubagentRuntimeOverrides,
SubagentSnapshotStatus,
SubagentSnapshotStatus, SubagentSpawnRequest,
};
use crate::notification::types::ToolNotificationHandle;
use crate::notification::{
@ -526,6 +526,7 @@ impl SchedulerActor {
.0
.send(SubagentEvent::Query(SubagentQueryRequest {
subagent_id: prev_id.clone(),
parent_session_id: Some(parent_session_id.clone()),
block: false,
timeout_ms: None,
respond_to,
@ -675,12 +676,14 @@ impl SchedulerActor {
fork_context: false,
owner: SubagentOwner::Task,
cancel_token: CancellationToken::new(),
result_tx,
};
if events
.0
.send(SubagentEvent::Spawn(Box::new(request)))
.send(SubagentEvent::Spawn(SubagentSpawnRequest {
request: Box::new(request),
result_tx,
}))
.is_err()
{
let mut res = self.resources.lock().await;
@ -1818,7 +1821,7 @@ mod tests {
let SubagentEvent::Spawn(spawn) = next_event(rx).await else {
panic!("expected subagent spawn");
};
spawn
spawn.request
}
async fn answer_loop_unit_active(

View file

@ -4,21 +4,21 @@
//! `TaskOutputTool`, `KillTaskTool`) from the transport mechanism used to
//! communicate with the subagent coordinator.
//!
//! Two implementations are planned:
//!
//! - [`ChannelBackend`] — wraps in-process `tokio::mpsc` channels used by
//! the local host shell. This is the only implementation today.
//! - `RemoteBackend` (future) — dispatches over a remote transport to an
//! out-of-process spawner.
//! All hosts use [`ChannelBackend`].
//! The receiver is owned by the shared single-writer coordinator actor; only
//! the child runner plugged into that actor differs by host.
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot};
use super::types::{
SubagentCancelOutcome, SubagentCancelRequest, SubagentCancelTarget, SubagentDescribeOutcome,
SubagentDescribeRequest, SubagentEvent, SubagentQueryRequest, SubagentRequest, SubagentResult,
SubagentSnapshot, SubagentValidateTypeOutcome, SubagentValidateTypeRequest,
SpawnedSubagentRef, SubagentCancelOutcome, SubagentCancelRequest, SubagentCancelTarget,
SubagentDescribeOutcome, SubagentDescribeRequest, SubagentEvent, SubagentInspectRequest,
SubagentInspection, SubagentListRunningRequest, SubagentQueryRequest, SubagentRegistryCounts,
SubagentRegistryCountsRequest, SubagentRequest, SubagentResult, SubagentSnapshot,
SubagentSpawnRequest, SubagentSpawnedRefsRequest, SubagentValidateTypeOutcome,
SubagentValidateTypeRequest,
};
use crate::register_resource;
use xai_tool_runtime::ToolError;
@ -110,13 +110,132 @@ register_resource!(
/// Wraps a single `mpsc::UnboundedSender<SubagentEvent>` that carries
/// spawn, query, and cancel messages to the coordinator. The oneshot for
/// `spawn` is created inside the backend so callers never manage it.
#[derive(Clone)]
pub struct ChannelBackend {
tx: mpsc::UnboundedSender<SubagentEvent>,
parent_session_id: Option<Arc<str>>,
}
impl ChannelBackend {
pub fn new(tx: mpsc::UnboundedSender<SubagentEvent>) -> Self {
Self { tx }
Self {
tx,
parent_session_id: None,
}
}
/// Bind model-facing operations to one parent session.
pub fn for_session(
tx: mpsc::UnboundedSender<SubagentEvent>,
parent_session_id: impl Into<Arc<str>>,
) -> Self {
Self {
tx,
parent_session_id: Some(parent_session_id.into()),
}
}
fn parent_session_id(&self) -> Option<String> {
self.parent_session_id.as_deref().map(str::to_owned)
}
pub fn sender(&self) -> mpsc::UnboundedSender<SubagentEvent> {
self.tx.clone()
}
pub fn into_resource(self) -> SubagentBackendResource {
SubagentBackendResource(Arc::new(self))
}
pub async fn cancel_parent_prompt(&self, parent_prompt_id: &str) -> SubagentCancelOutcome {
let (respond_to, response_rx) = oneshot::channel();
if self
.tx
.send(SubagentEvent::Cancel(SubagentCancelRequest {
parent_session_id: self.parent_session_id(),
target: SubagentCancelTarget::ParentPromptId(parent_prompt_id.to_owned()),
respond_to,
}))
.is_err()
{
return SubagentCancelOutcome::NotFound;
}
response_rx.await.unwrap_or(SubagentCancelOutcome::NotFound)
}
pub async fn inspect(&self, id: &str) -> Option<SubagentInspection> {
let (respond_to, response_rx) = oneshot::channel();
self.tx
.send(SubagentEvent::Inspect(SubagentInspectRequest {
subagent_id: id.to_owned(),
parent_session_id: self.parent_session_id(),
respond_to,
}))
.ok()?;
response_rx.await.ok().flatten()
}
pub async fn list_running(&self, parent_session_id: &str) -> Vec<SubagentInspection> {
let (respond_to, response_rx) = oneshot::channel();
if self
.tx
.send(SubagentEvent::ListRunning(SubagentListRunningRequest {
parent_session_id: parent_session_id.to_owned(),
respond_to,
}))
.is_err()
{
return Vec::new();
}
response_rx.await.unwrap_or_default()
}
pub async fn spawned_refs_for_prompt(
&self,
parent_session_id: &str,
prompt_id: &str,
) -> Vec<SpawnedSubagentRef> {
let (respond_to, response_rx) = oneshot::channel();
if self
.tx
.send(SubagentEvent::SpawnedRefs(SubagentSpawnedRefsRequest {
parent_session_id: self
.parent_session_id
.as_deref()
.unwrap_or(parent_session_id)
.to_owned(),
prompt_id: prompt_id.to_owned(),
respond_to,
}))
.is_err()
{
return Vec::new();
}
response_rx.await.unwrap_or_default()
}
pub async fn registry_counts(&self) -> SubagentRegistryCounts {
let (respond_to, response_rx) = oneshot::channel();
if self
.tx
.send(SubagentEvent::RegistryCounts(
SubagentRegistryCountsRequest { respond_to },
))
.is_err()
{
return SubagentRegistryCounts::default();
}
response_rx.await.unwrap_or_default()
}
/// Spawn while holding the host's interruptible foreground-wait token.
pub async fn spawn_with_foreground_wait(
&self,
request: SubagentRequest,
wait: Option<&super::types::SubagentForegroundWait>,
) -> Result<SubagentResult, ToolError> {
let _wait = wait.map(super::types::SubagentForegroundWait::enter);
self.spawn(request).await
}
}
@ -135,20 +254,18 @@ impl Drop for CancelResultReceiverOnDrop {
#[async_trait::async_trait]
impl SubagentBackend for ChannelBackend {
async fn spawn(&self, request: SubagentRequest) -> Result<SubagentResult, ToolError> {
let (result_tx, result_rx) = oneshot::channel();
async fn spawn(&self, mut request: SubagentRequest) -> Result<SubagentResult, ToolError> {
if let Some(parent_session_id) = self.parent_session_id.as_deref() {
request.parent_session_id = parent_session_id.to_owned();
}
let (respond_to, response_rx) = oneshot::channel();
let cancel_on_receiver_drop = request.owner.is_workflow();
let cancel_token = request.cancel_token.clone();
// Replace the dummy oneshot with our fresh one. Using struct update
// syntax (`..request`) ensures new fields added to `SubagentRequest`
// are forwarded automatically — a field-by-field copy would silently
// drop them.
self.tx
.send(SubagentEvent::Spawn(Box::new(SubagentRequest {
result_tx,
..request
})))
.send(SubagentEvent::Spawn(SubagentSpawnRequest {
request: Box::new(request),
result_tx: respond_to,
}))
.map_err(|_| {
ToolError::custom(
"channel_closed",
@ -160,7 +277,7 @@ impl SubagentBackend for ChannelBackend {
cancel_token: cancel_token.clone(),
armed: true,
});
let result = result_rx.await;
let result = response_rx.await;
if result.is_ok() {
if let Some(guard) = receiver_guard.as_mut() {
guard.armed = false;
@ -185,6 +302,7 @@ impl SubagentBackend for ChannelBackend {
let (respond_to, response_rx) = oneshot::channel();
let sent = self.tx.send(SubagentEvent::Query(SubagentQueryRequest {
subagent_id: id.to_string(),
parent_session_id: self.parent_session_id(),
block,
timeout_ms,
respond_to,
@ -198,6 +316,7 @@ impl SubagentBackend for ChannelBackend {
async fn cancel(&self, id: &str) -> SubagentCancelOutcome {
let (respond_to, response_rx) = oneshot::channel();
let sent = self.tx.send(SubagentEvent::Cancel(SubagentCancelRequest {
parent_session_id: self.parent_session_id(),
target: SubagentCancelTarget::SubagentId(id.to_string()),
respond_to,
}));
@ -212,6 +331,10 @@ impl SubagentBackend for ChannelBackend {
subagent_type: &str,
parent_session_id: &str,
) -> SubagentValidateTypeOutcome {
let parent_session_id = self
.parent_session_id
.as_deref()
.unwrap_or(parent_session_id);
let (respond_to, response_rx) = oneshot::channel();
if self
.tx
@ -255,6 +378,10 @@ impl SubagentBackend for ChannelBackend {
harness_agent_type: Option<&str>,
parent_session_id: &str,
) -> SubagentDescribeOutcome {
let parent_session_id = self
.parent_session_id
.as_deref()
.unwrap_or(parent_session_id);
let (respond_to, response_rx) = oneshot::channel();
if self
.tx
@ -301,7 +428,7 @@ pub const VALIDATE_TYPE_TIMEOUT: std::time::Duration = std::time::Duration::from
pub const VALIDATE_TYPE_TIMEOUT_ENV_VAR: &str = "XAI_VALIDATE_TYPE_TIMEOUT_MS";
/// Validation timeout, honoring the env-var override.
pub(crate) fn validate_type_timeout() -> std::time::Duration {
pub fn validate_type_timeout() -> std::time::Duration {
let raw = std::env::var(VALIDATE_TYPE_TIMEOUT_ENV_VAR).ok();
parse_timeout_ms(raw.as_deref())
.map(std::time::Duration::from_millis)
@ -313,604 +440,14 @@ pub(crate) fn parse_timeout_ms(value: Option<&str>) -> Option<u64> {
value?.parse::<u64>().ok().filter(|&ms| ms > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tokio::sync::mpsc;
/// Helper: receive the next event, match the expected variant, or panic.
macro_rules! recv_event {
($rx:expr, Spawn) => {{
let event = $rx.recv().await.unwrap();
match event {
SubagentEvent::Spawn(inner) => *inner,
_ => panic!("Expected SubagentEvent::Spawn, got different variant"),
}
}};
($rx:expr, $variant:ident) => {{
let event = $rx.recv().await.unwrap();
match event {
SubagentEvent::$variant(inner) => inner,
_ => panic!(
"Expected SubagentEvent::{}, got different variant",
stringify!($variant)
),
}
}};
}
#[tokio::test]
async fn channel_backend_spawn_success() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Spawn);
assert_eq!(req.id, "test-id");
assert_eq!(req.prompt, "do something");
req.result_tx
.send(SubagentResult {
success: true,
output: Arc::from("done"),
subagent_id: "test-id".to_string(),
child_session_id: "test-id".to_string(),
tool_calls: 3,
turns: 1,
duration_ms: 500,
..Default::default()
})
.unwrap();
});
let (dummy_tx, _dummy_rx) = oneshot::channel();
let request = SubagentRequest {
id: "test-id".to_string(),
prompt: "do something".to_string(),
description: "test".to_string(),
subagent_type: "general-purpose".to_string(),
parent_session_id: "parent".to_string(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: true,
await_to_completion: false,
fork_context: false,
owner: super::super::types::SubagentOwner::Task,
cancel_token: tokio_util::sync::CancellationToken::new(),
result_tx: dummy_tx,
};
let result = backend.spawn(request).await.unwrap();
assert!(result.success);
assert_eq!(result.subagent_id, "test-id");
assert_eq!(result.tool_calls, 3);
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_spawn_closed_channel() {
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
let (dummy_tx, _dummy_rx) = oneshot::channel();
let request = SubagentRequest {
id: "test-id".to_string(),
prompt: "do something".to_string(),
description: "test".to_string(),
subagent_type: "general-purpose".to_string(),
parent_session_id: "parent".to_string(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: true,
await_to_completion: false,
fork_context: false,
owner: super::super::types::SubagentOwner::Task,
cancel_token: tokio_util::sync::CancellationToken::new(),
result_tx: dummy_tx,
};
let err = backend.spawn(request).await.unwrap_err();
assert!(err.to_string().contains("channel closed"));
}
#[tokio::test]
async fn channel_backend_query_found() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Query);
assert_eq!(req.subagent_id, "sub-1");
assert!(req.block);
assert_eq!(req.timeout_ms, Some(5000));
req.respond_to
.send(Some(SubagentSnapshot {
subagent_id: "sub-1".to_string(),
description: "find bugs".to_string(),
subagent_type: "explore".to_string(),
status: super::super::types::SubagentSnapshotStatus::Completed {
output: "result".to_string(),
tool_calls: 2,
turns: 1,
worktree_path: None,
},
started_at_epoch_ms: 1000,
duration_ms: 200,
persona: Some("reviewer".to_string()),
}))
.unwrap();
});
let snap = backend.query("sub-1", true, Some(5000)).await;
let snap = snap.expect("snapshot should be present");
assert_eq!(snap.subagent_id, "sub-1");
assert_eq!(snap.description, "find bugs");
assert_eq!(snap.subagent_type, "explore");
assert_eq!(snap.started_at_epoch_ms, 1000);
assert_eq!(snap.duration_ms, 200);
assert_eq!(snap.persona.as_deref(), Some("reviewer"));
match &snap.status {
super::super::types::SubagentSnapshotStatus::Completed {
output,
tool_calls,
turns,
worktree_path,
} => {
assert_eq!(output, "result");
assert_eq!(*tool_calls, 2);
assert_eq!(*turns, 1);
assert!(worktree_path.is_none());
}
other => panic!("Expected Completed, got {:?}", other),
}
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_query_non_blocking_passes_through() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Query);
assert_eq!(req.subagent_id, "sub-nb");
assert!(!req.block, "block should be false");
assert_eq!(req.timeout_ms, None, "timeout_ms should be None");
req.respond_to.send(None).unwrap();
});
let snap = backend.query("sub-nb", false, None).await;
assert!(snap.is_none());
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_query_not_found() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Query);
req.respond_to.send(None).unwrap();
});
let snap = backend.query("nonexistent", false, None).await;
assert!(snap.is_none());
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_cancel_success() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Cancel);
match &req.target {
SubagentCancelTarget::SubagentId(id) => assert_eq!(id, "sub-cancel"),
other => panic!("Expected SubagentId, got {:?}", other),
}
req.respond_to
.send(SubagentCancelOutcome::Cancelled)
.unwrap();
});
let outcome = backend.cancel("sub-cancel").await;
assert!(matches!(outcome, SubagentCancelOutcome::Cancelled));
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_cancel_closed_channel() {
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
let outcome = backend.cancel("sub-cancel").await;
assert!(matches!(outcome, SubagentCancelOutcome::NotFound));
}
#[tokio::test]
async fn workflow_spawn_future_drop_cancels_but_task_drop_does_not() {
fn request_for(owner: super::super::types::SubagentOwner) -> SubagentRequest {
let (dummy_tx, _dummy_rx) = oneshot::channel();
SubagentRequest {
id: "drop-owner-test".to_string(),
prompt: "test".to_string(),
description: "test".to_string(),
subagent_type: "general-purpose".to_string(),
parent_session_id: "parent".to_string(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: false,
await_to_completion: true,
fork_context: false,
owner,
cancel_token: tokio_util::sync::CancellationToken::new(),
result_tx: dummy_tx,
}
}
for (owner, should_cancel) in [
(super::super::types::SubagentOwner::Task, false),
(super::super::types::SubagentOwner::workflow("wf-1"), true),
] {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = Arc::new(ChannelBackend::new(tx));
let request = request_for(owner);
let cancel_token = request.cancel_token.clone();
let task = tokio::spawn({
let backend = backend.clone();
async move { backend.spawn(request).await }
});
let spawned = recv_event!(rx, Spawn);
task.abort();
let _ = task.await;
assert_eq!(
cancel_token.is_cancelled(),
should_cancel,
"only workflow receiver drop owns cancellation"
);
drop(spawned.result_tx);
}
}
#[tokio::test]
async fn channel_backend_spawn_result_dropped() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Spawn);
drop(req.result_tx);
});
let (dummy_tx, _dummy_rx) = oneshot::channel();
let request = SubagentRequest {
id: "drop-test".to_string(),
prompt: "test".to_string(),
description: "test".to_string(),
subagent_type: "general-purpose".to_string(),
parent_session_id: "parent".to_string(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: true,
await_to_completion: false,
fork_context: false,
owner: super::super::types::SubagentOwner::Task,
cancel_token: tokio_util::sync::CancellationToken::new(),
result_tx: dummy_tx,
};
let err = backend.spawn(request).await.unwrap_err();
assert!(
err.to_string().contains("result channel dropped"),
"error: {err}"
);
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_query_closed_channel() {
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
let snap = backend.query("sub-1", false, None).await;
assert!(snap.is_none());
}
// ── validate_type ────────────────────────────────────────────────
#[tokio::test]
async fn channel_backend_validate_type_round_trips_outcome() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let event = rx.recv().await.unwrap();
match event {
SubagentEvent::ValidateType(req) => {
assert_eq!(req.subagent_type, "explore");
assert_eq!(req.parent_session_id, "parent-1");
req.respond_to
.send(SubagentValidateTypeOutcome::Ok)
.unwrap();
}
_ => panic!("Expected ValidateType event"),
}
});
let outcome = backend.validate_type("explore", "parent-1").await;
assert!(matches!(outcome, SubagentValidateTypeOutcome::Ok));
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_validate_type_propagates_unknown_outcome() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
req.respond_to
.send(SubagentValidateTypeOutcome::Unknown {
available: vec!["explore".into(), "plan".into()],
})
.unwrap();
}
});
let outcome = backend.validate_type("invented", "p").await;
match outcome {
SubagentValidateTypeOutcome::Unknown { available } => {
assert_eq!(available, vec!["explore".to_string(), "plan".to_string()]);
}
other => panic!("expected Unknown, got {other:?}"),
}
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_validate_type_returns_validation_unavailable_when_channel_closed() {
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
let outcome = backend.validate_type("explore", "p").await;
assert!(matches!(
outcome,
SubagentValidateTypeOutcome::ValidationUnavailable
));
}
#[tokio::test]
async fn channel_backend_validate_type_returns_validation_unavailable_when_responder_dropped() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
drop(req.respond_to);
}
});
let outcome = backend.validate_type("explore", "p").await;
assert!(matches!(
outcome,
SubagentValidateTypeOutcome::ValidationUnavailable,
));
handle.await.unwrap();
}
use super::super::types::test_capture;
#[tokio::test(start_paused = true)]
async fn channel_backend_validate_type_logs_warn_on_timeout() {
let captured = test_capture::capture();
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
// Coordinator receives but never replies; keeps the responder
// alive so the timeout arm fires (not responder-dropped).
let holder = tokio::spawn(async move {
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
std::mem::forget(req.respond_to);
std::future::pending::<()>().await;
}
});
let validate = tokio::spawn(async move { backend.validate_type("explore", "p").await });
tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await;
let outcome = validate.await.unwrap();
assert!(matches!(
outcome,
SubagentValidateTypeOutcome::ValidationUnavailable
));
let mut events_rx = captured.events_rx;
let mut saw_timeout_warn = false;
while let Ok(event) = events_rx.try_recv() {
if event.level == tracing::Level::WARN
&& event.fields.contains("coordinator validation timed out")
&& event.fields.contains("subagent_type=explore")
&& event.fields.contains("timeout_ms=")
{
saw_timeout_warn = true;
break;
}
}
assert!(saw_timeout_warn, "must emit WARN with timeout_ms field");
holder.abort();
}
// ── describe_subagent_type ───────────────────────────────────────
#[tokio::test]
async fn channel_backend_describe_round_trips_summary() {
use super::super::types::{SubagentDescribeOutcome, SubagentTypeSummary};
use crate::types::tool::ToolKind;
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
match rx.recv().await.unwrap() {
SubagentEvent::DescribeType(req) => {
assert_eq!(req.subagent_type, "explore");
assert_eq!(req.harness_agent_type.as_deref(), Some("cursor"));
assert_eq!(req.parent_session_id, "parent-1");
let mut summary = SubagentTypeSummary {
can_read: true,
can_search: true,
..Default::default()
};
summary
.tool_names
.insert(ToolKind::Read, "read_file".to_string());
req.respond_to
.send(SubagentDescribeOutcome::Ok(summary))
.unwrap();
}
_ => panic!("Expected DescribeType event"),
}
});
let outcome = backend
.describe_subagent_type("explore", Some("cursor"), "parent-1")
.await;
match outcome {
SubagentDescribeOutcome::Ok(summary) => {
assert!(summary.can_read && summary.can_search && !summary.can_execute);
assert_eq!(
summary.tool_names.get(&ToolKind::Read).unwrap(),
"read_file"
);
}
other => panic!("expected Ok, got {other:?}"),
}
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_describe_propagates_not_allowed_outcome() {
use super::super::types::SubagentDescribeOutcome;
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
req.respond_to
.send(SubagentDescribeOutcome::NotAllowed {
allowed: vec!["explore".into()],
})
.unwrap();
}
});
match backend.describe_subagent_type("plan", None, "p").await {
SubagentDescribeOutcome::NotAllowed { allowed } => {
assert_eq!(allowed, vec!["explore".to_string()]);
}
other => panic!("expected NotAllowed, got {other:?}"),
}
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_describe_returns_unavailable_when_channel_closed() {
use super::super::types::SubagentDescribeOutcome;
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
assert!(matches!(
backend.describe_subagent_type("explore", None, "p").await,
SubagentDescribeOutcome::Unavailable
));
}
#[tokio::test]
async fn channel_backend_describe_returns_unavailable_when_responder_dropped() {
use super::super::types::SubagentDescribeOutcome;
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
drop(req.respond_to);
}
});
assert!(matches!(
backend.describe_subagent_type("explore", None, "p").await,
SubagentDescribeOutcome::Unavailable
));
handle.await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn channel_backend_describe_returns_unavailable_on_timeout() {
use super::super::types::SubagentDescribeOutcome;
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let holder = tokio::spawn(async move {
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
std::mem::forget(req.respond_to);
std::future::pending::<()>().await;
}
});
let describe =
tokio::spawn(async move { backend.describe_subagent_type("explore", None, "p").await });
tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await;
assert!(matches!(
describe.await.unwrap(),
SubagentDescribeOutcome::Unavailable
));
holder.abort();
}
#[test]
fn parse_timeout_ms_returns_none_for_unset() {
assert_eq!(parse_timeout_ms(None), None);
}
#[test]
fn parse_timeout_ms_returns_none_for_unparseable() {
assert_eq!(parse_timeout_ms(Some("not-a-number")), None);
assert_eq!(parse_timeout_ms(Some("")), None);
assert_eq!(parse_timeout_ms(Some("3.14")), None);
assert_eq!(parse_timeout_ms(Some("-100")), None);
}
#[test]
fn parse_timeout_ms_returns_none_for_zero() {
assert_eq!(parse_timeout_ms(Some("0")), None);
}
#[test]
fn parse_timeout_ms_returns_value_for_positive_integer() {
assert_eq!(parse_timeout_ms(Some("5000")), Some(5000));
assert_eq!(parse_timeout_ms(Some("1")), Some(1));
}
/// Resolve a `Duration` from a positive-millisecond env override, falling back
/// to `default` when the var is unset / non-numeric / zero.
pub fn env_duration_or(env_var: &str, default: std::time::Duration) -> std::time::Duration {
parse_timeout_ms(std::env::var(env_var).ok().as_deref())
.map(std::time::Duration::from_millis)
.unwrap_or(default)
}
#[cfg(test)]
#[path = "backend_tests.rs"]
mod tests;

View file

@ -0,0 +1,590 @@
use super::*;
use std::sync::Arc;
use tokio::sync::mpsc;
/// Helper: receive the next event, match the expected variant, or panic.
macro_rules! recv_event {
($rx:expr, Spawn) => {{
let event = $rx.recv().await.unwrap();
match event {
SubagentEvent::Spawn(inner) => inner,
_ => panic!("Expected SubagentEvent::Spawn, got different variant"),
}
}};
($rx:expr, $variant:ident) => {{
let event = $rx.recv().await.unwrap();
match event {
SubagentEvent::$variant(inner) => inner,
_ => panic!(
"Expected SubagentEvent::{}, got different variant",
stringify!($variant)
),
}
}};
}
#[tokio::test]
async fn channel_backend_spawn_success() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Spawn);
assert_eq!(req.request.id, "test-id");
assert_eq!(req.request.prompt, "do something");
req.result_tx
.send(SubagentResult {
success: true,
output: Arc::from("done"),
subagent_id: "test-id".to_string(),
child_session_id: "test-id".to_string(),
tool_calls: 3,
turns: 1,
duration_ms: 500,
..Default::default()
})
.unwrap();
});
let request = SubagentRequest {
id: "test-id".to_string(),
prompt: "do something".to_string(),
description: "test".to_string(),
subagent_type: "general-purpose".to_string(),
parent_session_id: "parent".to_string(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: true,
await_to_completion: false,
fork_context: false,
owner: super::super::types::SubagentOwner::Task,
cancel_token: tokio_util::sync::CancellationToken::new(),
};
let result = backend.spawn(request).await.unwrap();
assert!(result.success);
assert_eq!(result.subagent_id, "test-id");
assert_eq!(result.tool_calls, 3);
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_spawn_closed_channel() {
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
let request = SubagentRequest {
id: "test-id".to_string(),
prompt: "do something".to_string(),
description: "test".to_string(),
subagent_type: "general-purpose".to_string(),
parent_session_id: "parent".to_string(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: true,
await_to_completion: false,
fork_context: false,
owner: super::super::types::SubagentOwner::Task,
cancel_token: tokio_util::sync::CancellationToken::new(),
};
let err = backend.spawn(request).await.unwrap_err();
assert!(err.to_string().contains("channel closed"));
}
#[tokio::test]
async fn channel_backend_query_found() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Query);
assert_eq!(req.subagent_id, "sub-1");
assert!(req.block);
assert_eq!(req.timeout_ms, Some(5000));
req.respond_to
.send(Some(SubagentSnapshot {
subagent_id: "sub-1".to_string(),
description: "find bugs".to_string(),
subagent_type: "explore".to_string(),
status: super::super::types::SubagentSnapshotStatus::Completed {
output: "result".to_string(),
tool_calls: 2,
turns: 1,
worktree_path: None,
},
started_at_epoch_ms: 1000,
duration_ms: 200,
persona: Some("reviewer".to_string()),
}))
.unwrap();
});
let snap = backend.query("sub-1", true, Some(5000)).await;
let snap = snap.expect("snapshot should be present");
assert_eq!(snap.subagent_id, "sub-1");
assert_eq!(snap.description, "find bugs");
assert_eq!(snap.subagent_type, "explore");
assert_eq!(snap.started_at_epoch_ms, 1000);
assert_eq!(snap.duration_ms, 200);
assert_eq!(snap.persona.as_deref(), Some("reviewer"));
match &snap.status {
super::super::types::SubagentSnapshotStatus::Completed {
output,
tool_calls,
turns,
worktree_path,
} => {
assert_eq!(output, "result");
assert_eq!(*tool_calls, 2);
assert_eq!(*turns, 1);
assert!(worktree_path.is_none());
}
other => panic!("Expected Completed, got {:?}", other),
}
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_query_non_blocking_passes_through() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Query);
assert_eq!(req.subagent_id, "sub-nb");
assert!(!req.block, "block should be false");
assert_eq!(req.timeout_ms, None, "timeout_ms should be None");
req.respond_to.send(None).unwrap();
});
let snap = backend.query("sub-nb", false, None).await;
assert!(snap.is_none());
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_query_not_found() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Query);
req.respond_to.send(None).unwrap();
});
let snap = backend.query("nonexistent", false, None).await;
assert!(snap.is_none());
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_cancel_success() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Cancel);
match &req.target {
SubagentCancelTarget::SubagentId(id) => assert_eq!(id, "sub-cancel"),
other => panic!("Expected SubagentId, got {:?}", other),
}
req.respond_to
.send(SubagentCancelOutcome::Cancelled)
.unwrap();
});
let outcome = backend.cancel("sub-cancel").await;
assert!(matches!(outcome, SubagentCancelOutcome::Cancelled));
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_cancel_closed_channel() {
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
let outcome = backend.cancel("sub-cancel").await;
assert!(matches!(outcome, SubagentCancelOutcome::NotFound));
}
#[tokio::test]
async fn workflow_spawn_future_drop_cancels_but_task_drop_does_not() {
fn request_for(owner: super::super::types::SubagentOwner) -> SubagentRequest {
SubagentRequest {
id: "drop-owner-test".to_string(),
prompt: "test".to_string(),
description: "test".to_string(),
subagent_type: "general-purpose".to_string(),
parent_session_id: "parent".to_string(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: false,
await_to_completion: true,
fork_context: false,
owner,
cancel_token: tokio_util::sync::CancellationToken::new(),
}
}
for (owner, should_cancel) in [
(super::super::types::SubagentOwner::Task, false),
(super::super::types::SubagentOwner::workflow("wf-1"), true),
] {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = Arc::new(ChannelBackend::new(tx));
let request = request_for(owner);
let cancel_token = request.cancel_token.clone();
let task = tokio::spawn({
let backend = backend.clone();
async move { backend.spawn(request).await }
});
let spawned = recv_event!(rx, Spawn);
task.abort();
let _ = task.await;
assert_eq!(
cancel_token.is_cancelled(),
should_cancel,
"only workflow receiver drop owns cancellation"
);
drop(spawned.result_tx);
}
}
#[tokio::test]
async fn channel_backend_spawn_result_dropped() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let req = recv_event!(rx, Spawn);
drop(req.result_tx);
});
let request = SubagentRequest {
id: "drop-test".to_string(),
prompt: "test".to_string(),
description: "test".to_string(),
subagent_type: "general-purpose".to_string(),
parent_session_id: "parent".to_string(),
parent_prompt_id: None,
resume_from: None,
cwd: None,
runtime_overrides: Default::default(),
run_in_background: false,
surface_completion: true,
await_to_completion: false,
fork_context: false,
owner: super::super::types::SubagentOwner::Task,
cancel_token: tokio_util::sync::CancellationToken::new(),
};
let err = backend.spawn(request).await.unwrap_err();
assert!(
err.to_string().contains("result channel dropped"),
"error: {err}"
);
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_query_closed_channel() {
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
let snap = backend.query("sub-1", false, None).await;
assert!(snap.is_none());
}
// ── validate_type ────────────────────────────────────────────────
#[tokio::test]
async fn channel_backend_validate_type_round_trips_outcome() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
let event = rx.recv().await.unwrap();
match event {
SubagentEvent::ValidateType(req) => {
assert_eq!(req.subagent_type, "explore");
assert_eq!(req.parent_session_id, "parent-1");
req.respond_to
.send(SubagentValidateTypeOutcome::Ok)
.unwrap();
}
_ => panic!("Expected ValidateType event"),
}
});
let outcome = backend.validate_type("explore", "parent-1").await;
assert!(matches!(outcome, SubagentValidateTypeOutcome::Ok));
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_validate_type_propagates_unknown_outcome() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
req.respond_to
.send(SubagentValidateTypeOutcome::Unknown {
available: vec!["explore".into(), "plan".into()],
})
.unwrap();
}
});
let outcome = backend.validate_type("invented", "p").await;
match outcome {
SubagentValidateTypeOutcome::Unknown { available } => {
assert_eq!(available, vec!["explore".to_string(), "plan".to_string()]);
}
other => panic!("expected Unknown, got {other:?}"),
}
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_validate_type_returns_validation_unavailable_when_channel_closed() {
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
let outcome = backend.validate_type("explore", "p").await;
assert!(matches!(
outcome,
SubagentValidateTypeOutcome::ValidationUnavailable
));
}
#[tokio::test]
async fn channel_backend_validate_type_returns_validation_unavailable_when_responder_dropped() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
drop(req.respond_to);
}
});
let outcome = backend.validate_type("explore", "p").await;
assert!(matches!(
outcome,
SubagentValidateTypeOutcome::ValidationUnavailable,
));
handle.await.unwrap();
}
use super::super::types::test_capture;
#[tokio::test(start_paused = true)]
async fn channel_backend_validate_type_logs_warn_on_timeout() {
let captured = test_capture::capture();
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
// Coordinator receives but never replies; keeps the responder
// alive so the timeout arm fires (not responder-dropped).
let holder = tokio::spawn(async move {
if let Some(SubagentEvent::ValidateType(req)) = rx.recv().await {
std::mem::forget(req.respond_to);
std::future::pending::<()>().await;
}
});
let validate = tokio::spawn(async move { backend.validate_type("explore", "p").await });
tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await;
let outcome = validate.await.unwrap();
assert!(matches!(
outcome,
SubagentValidateTypeOutcome::ValidationUnavailable
));
let mut events_rx = captured.events_rx;
let mut saw_timeout_warn = false;
while let Ok(event) = events_rx.try_recv() {
if event.level == tracing::Level::WARN
&& event.fields.contains("coordinator validation timed out")
&& event.fields.contains("subagent_type=explore")
&& event.fields.contains("timeout_ms=")
{
saw_timeout_warn = true;
break;
}
}
assert!(saw_timeout_warn, "must emit WARN with timeout_ms field");
holder.abort();
}
// ── describe_subagent_type ───────────────────────────────────────
#[tokio::test]
async fn channel_backend_describe_round_trips_summary() {
use super::super::types::{SubagentDescribeOutcome, SubagentTypeSummary};
use crate::types::tool::ToolKind;
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
match rx.recv().await.unwrap() {
SubagentEvent::DescribeType(req) => {
assert_eq!(req.subagent_type, "explore");
assert_eq!(req.harness_agent_type.as_deref(), Some("cursor"));
assert_eq!(req.parent_session_id, "parent-1");
let mut summary = SubagentTypeSummary {
can_read: true,
can_search: true,
..Default::default()
};
summary
.tool_names
.insert(ToolKind::Read, "read_file".to_string());
req.respond_to
.send(SubagentDescribeOutcome::Ok(summary))
.unwrap();
}
_ => panic!("Expected DescribeType event"),
}
});
let outcome = backend
.describe_subagent_type("explore", Some("cursor"), "parent-1")
.await;
match outcome {
SubagentDescribeOutcome::Ok(summary) => {
assert!(summary.can_read && summary.can_search && !summary.can_execute);
assert_eq!(
summary.tool_names.get(&ToolKind::Read).unwrap(),
"read_file"
);
}
other => panic!("expected Ok, got {other:?}"),
}
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_describe_propagates_not_allowed_outcome() {
use super::super::types::SubagentDescribeOutcome;
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
req.respond_to
.send(SubagentDescribeOutcome::NotAllowed {
allowed: vec!["explore".into()],
})
.unwrap();
}
});
match backend.describe_subagent_type("plan", None, "p").await {
SubagentDescribeOutcome::NotAllowed { allowed } => {
assert_eq!(allowed, vec!["explore".to_string()]);
}
other => panic!("expected NotAllowed, got {other:?}"),
}
handle.await.unwrap();
}
#[tokio::test]
async fn channel_backend_describe_returns_unavailable_when_channel_closed() {
use super::super::types::SubagentDescribeOutcome;
let (tx, rx) = mpsc::unbounded_channel::<SubagentEvent>();
drop(rx);
let backend = ChannelBackend::new(tx);
assert!(matches!(
backend.describe_subagent_type("explore", None, "p").await,
SubagentDescribeOutcome::Unavailable
));
}
#[tokio::test]
async fn channel_backend_describe_returns_unavailable_when_responder_dropped() {
use super::super::types::SubagentDescribeOutcome;
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let handle = tokio::spawn(async move {
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
drop(req.respond_to);
}
});
assert!(matches!(
backend.describe_subagent_type("explore", None, "p").await,
SubagentDescribeOutcome::Unavailable
));
handle.await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn channel_backend_describe_returns_unavailable_on_timeout() {
use super::super::types::SubagentDescribeOutcome;
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
let backend = ChannelBackend::new(tx);
let holder = tokio::spawn(async move {
if let Some(SubagentEvent::DescribeType(req)) = rx.recv().await {
std::mem::forget(req.respond_to);
std::future::pending::<()>().await;
}
});
let describe =
tokio::spawn(async move { backend.describe_subagent_type("explore", None, "p").await });
tokio::time::advance(VALIDATE_TYPE_TIMEOUT + std::time::Duration::from_millis(1)).await;
assert!(matches!(
describe.await.unwrap(),
SubagentDescribeOutcome::Unavailable
));
holder.abort();
}
#[test]
fn parse_timeout_ms_returns_none_for_unset() {
assert_eq!(parse_timeout_ms(None), None);
}
#[test]
fn parse_timeout_ms_returns_none_for_unparseable() {
assert_eq!(parse_timeout_ms(Some("not-a-number")), None);
assert_eq!(parse_timeout_ms(Some("")), None);
assert_eq!(parse_timeout_ms(Some("3.14")), None);
assert_eq!(parse_timeout_ms(Some("-100")), None);
}
#[test]
fn parse_timeout_ms_returns_none_for_zero() {
assert_eq!(parse_timeout_ms(Some("0")), None);
}
#[test]
fn parse_timeout_ms_returns_value_for_positive_integer() {
assert_eq!(parse_timeout_ms(Some("5000")), Some(5000));
assert_eq!(parse_timeout_ms(Some("1")), Some(1));
}

View file

@ -0,0 +1,840 @@
//! Single-writer subagent coordinator actor.
//!
//! The actor owns the command receiver, pending/active/completed state,
//! concrete blocking waiters, foreground deadlines, cancellation, and the
//! terminal delivery disposition. All hosts drive it through `ChannelBackend`;
//! only their `ChildRunner` implementations differ.
//!
//! There is intentionally no shared mutable state in this module. A runner's
//! associated futures may be `Send` or non-`Send`; the resulting actor future
//! inherits that property naturally on stable Rust.
mod query;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use futures::FutureExt;
use futures::stream::{FuturesUnordered, StreamExt};
use tokio::sync::{mpsc, oneshot};
use super::coordinator_state::{
ActiveChild, BlockingWaiter, BufferedCompletion, ChildRecord, CompletedChild, InternalEvent,
ListRequest, MAX_COMPLETED_ENTRIES, PendingChild, ProgressFuture, ProgressTarget, ReplyFuture,
TaggedFuture, active_summary, background_at_deadline, background_if_caller_gone,
completed_snapshot, completion_summary, sleep_until, workflow_outstanding,
};
use super::types::{
SpawnedSubagentRef, SubagentCancelOutcome, SubagentCancelTarget, SubagentDescribeOutcome,
SubagentEvent, SubagentOutstandingReply, SubagentRegistryCounts, SubagentRequest,
SubagentResult, SubagentResumeLookup, SubagentResumeSource, SubagentValidateTypeOutcome,
};
pub use super::coordinator_state::{
ChildCompletion, ChildControl, ChildReporter, ChildRunOutput, ChildRunRequest, ChildRunner,
CompletionDisposition, CoordinatorConfig, LocalBoxFuture, SendBoxFuture, StartedChild,
SubagentProgress,
};
/// Channel-owned subagent lifecycle actor.
pub struct SubagentCoordinator<R: ChildRunner> {
commands: mpsc::UnboundedReceiver<SubagentEvent>,
internal_tx: mpsc::UnboundedSender<InternalEvent<R::Control>>,
internal_rx: mpsc::UnboundedReceiver<InternalEvent<R::Control>>,
runner: R,
config: CoordinatorConfig,
pending: HashMap<String, PendingChild>,
active: HashMap<String, ActiveChild<R::Control>>,
completed: HashMap<String, CompletedChild>,
completed_order: VecDeque<String>,
waiters: HashMap<String, Vec<BlockingWaiter>>,
workflow_cancel_waiters: HashMap<String, Vec<oneshot::Sender<SubagentCancelOutcome>>>,
usage_not_applied_prompts: HashSet<PromptScope>,
pending_completions: Vec<BufferedCompletion>,
runs: FuturesUnordered<
TaggedFuture<futures::future::CatchUnwind<std::panic::AssertUnwindSafe<R::RunFuture>>>,
>,
validations: FuturesUnordered<ReplyFuture<R::ValidateFuture, SubagentValidateTypeOutcome>>,
descriptions: FuturesUnordered<ReplyFuture<R::DescribeFuture, SubagentDescribeOutcome>>,
progress: FuturesUnordered<ProgressFuture<<R::Control as ChildControl>::ProgressFuture>>,
list_requests: HashMap<u64, ListRequest>,
next_list_request_id: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct PromptScope {
parent_session_id: String,
prompt_id: String,
}
impl PromptScope {
fn new(parent_session_id: String, prompt_id: String) -> Self {
Self {
parent_session_id,
prompt_id,
}
}
}
impl<R: ChildRunner> SubagentCoordinator<R> {
pub fn new(
commands: mpsc::UnboundedReceiver<SubagentEvent>,
runner: R,
config: CoordinatorConfig,
) -> Self {
let (internal_tx, internal_rx) = mpsc::unbounded_channel();
Self {
commands,
internal_tx,
internal_rx,
runner,
config,
pending: HashMap::new(),
active: HashMap::new(),
completed: HashMap::new(),
completed_order: VecDeque::new(),
waiters: HashMap::new(),
workflow_cancel_waiters: HashMap::new(),
usage_not_applied_prompts: HashSet::new(),
pending_completions: Vec::new(),
runs: FuturesUnordered::new(),
validations: FuturesUnordered::new(),
descriptions: FuturesUnordered::new(),
progress: FuturesUnordered::new(),
list_requests: HashMap::new(),
next_list_request_id: 0,
}
}
pub async fn run(mut self) {
let mut commands_open = true;
loop {
if !commands_open
&& self.runs.is_empty()
&& self.validations.is_empty()
&& self.descriptions.is_empty()
&& self.progress.is_empty()
{
break;
}
let deadline = self.next_deadline();
tokio::select! {
biased;
Some(event) = self.internal_rx.recv() => self.handle_internal(event),
Some((id, output)) = self.runs.next(), if !self.runs.is_empty() => {
match output {
Ok(output) => self.finish_child(&id, output),
Err(_) => self.finish_panicked_child(&id),
}
}
Some((respond_to, outcome)) = self.validations.next(), if !self.validations.is_empty() => {
let _ = respond_to.send(outcome);
}
Some((respond_to, outcome)) = self.descriptions.next(), if !self.descriptions.is_empty() => {
let _ = respond_to.send(outcome);
}
Some((seed, target, progress)) = self.progress.next(), if !self.progress.is_empty() => {
self.finish_progress(seed, target, progress);
}
command = self.commands.recv(), if commands_open => {
match command {
Some(command) => {
self.reap_abandoned_callers();
self.handle_command(command);
}
None => commands_open = false,
}
}
_ = sleep_until(deadline), if deadline.is_some() => self.process_deadlines(),
}
while self.completed.len() > MAX_COMPLETED_ENTRIES {
let Some(id) = self.completed_order.pop_front() else {
break;
};
self.completed.remove(&id);
}
}
self.cancel_all_children();
}
fn handle_command(&mut self, command: SubagentEvent) {
match command {
SubagentEvent::Spawn(command) => {
let mut request = *command.request;
if let Some((root_parent, loop_task_id)) = self
.active
.values()
.find(|child| child.child_session_id == request.parent_session_id)
.map(|child| {
(
child.request.parent_session_id.clone(),
child.request.runtime_overrides.loop_task_id.clone(),
)
})
{
request.parent_session_id = root_parent;
request.surface_completion = false;
if request.runtime_overrides.loop_task_id.is_none() {
request.runtime_overrides.loop_task_id = loop_task_id;
}
}
let id = request.id.clone();
if self.pending.contains_key(&id)
|| self.active.contains_key(&id)
|| self.completed.contains_key(&id)
{
let _ = command.result_tx.send(SubagentResult {
success: false,
error: Some(format!("Subagent id '{id}' already exists")),
subagent_id: id.clone(),
child_session_id: id,
..Default::default()
});
return;
}
let cancellation = request.cancel_token.clone();
let handle_only = request.run_in_background;
let foreground_deadline = (!request.run_in_background
&& !request.await_to_completion)
.then(|| tokio::time::Instant::now() + self.config.foreground_budget);
self.pending.insert(
id.clone(),
PendingChild {
request: request.clone(),
started_at: std::time::Instant::now(),
cancellation: cancellation.clone(),
spawn_reply: Some(command.result_tx),
foreground_deadline,
handle_only,
explicitly_killed: false,
},
);
self.running_count_changed();
let reporter = ChildReporter {
subagent_id: id.clone(),
tx: self.internal_tx.clone(),
};
self.runs.push(TaggedFuture {
subagent_id: id,
future: Box::pin(
std::panic::AssertUnwindSafe(self.runner.run(ChildRunRequest {
request,
cancellation,
reporter,
}))
.catch_unwind(),
),
});
}
SubagentEvent::Query(query) => {
self.handle_query(
query.subagent_id,
query.parent_session_id,
query.block,
query.timeout_ms,
query.respond_to,
);
}
SubagentEvent::Cancel(request) => match request.target {
SubagentCancelTarget::SubagentId(id) => {
let outcome = self.cancel_one(&id, request.parent_session_id.as_deref(), true);
let _ = request.respond_to.send(outcome);
}
SubagentCancelTarget::ParentPromptId(prompt_id) => {
self.cancel_parent_prompt(&prompt_id, request.parent_session_id.as_deref());
let _ = request.respond_to.send(SubagentCancelOutcome::Cancelled);
}
SubagentCancelTarget::WorkflowRunId(run_id) => {
self.cancel_workflow_children(&run_id, request.parent_session_id.as_deref());
if workflow_outstanding(&self.pending, &self.active, &run_id) == 0 {
let _ = request.respond_to.send(SubagentCancelOutcome::Cancelled);
} else {
self.workflow_cancel_waiters
.entry(run_id)
.or_default()
.push(request.respond_to);
}
}
},
SubagentEvent::ListActive(request) => {
let summaries = self
.active
.values()
.filter(|child| {
child.request.parent_session_id == request.parent_session_id
&& !child.request.owner.is_workflow()
})
.map(active_summary)
.collect();
let _ = request.respond_to.send(summaries);
}
SubagentEvent::ListRunning(request) => {
self.handle_list_running(request.parent_session_id, request.respond_to);
}
SubagentEvent::Completions(request) => {
let (owned, foreign): (Vec<_>, Vec<_>) =
std::mem::take(&mut self.pending_completions)
.into_iter()
.partition(|completion| {
request
.parent_session_id
.as_ref()
.is_none_or(|id| completion.parent_session_id == *id)
});
self.pending_completions = foreign;
let completions = owned
.into_iter()
.map(|completion| completion.summary)
.filter(|summary| !request.suppress_ids.contains(&summary.subagent_id))
.collect();
let _ = request.respond_to.send(completions);
}
SubagentEvent::DiscardSessionCompletions { parent_session_id } => {
self.pending_completions
.retain(|completion| completion.parent_session_id != parent_session_id);
}
SubagentEvent::Outstanding(request) => {
// Reap again here so turn-freeze / Outstanding polls see
// ParentGone even if no other command woke the actor first.
self.reap_abandoned_callers();
let mut live_ids: Vec<_> = self
.pending
.values()
.filter(|child| {
child.request.parent_session_id == request.parent_session_id
&& child.request.parent_prompt_id.as_deref() == Some(&request.prompt_id)
&& !child.request.owner.is_workflow()
&& !child.handle_only
})
.map(|child| child.request.id.clone())
.chain(
self.active
.values()
.filter(|child| {
child.request.parent_session_id == request.parent_session_id
&& child.request.parent_prompt_id.as_deref()
== Some(&request.prompt_id)
&& !child.request.owner.is_workflow()
// Definition-declared background children are
// background for accounting even while the
// spawning tool block-awaits them.
&& !child.handle_only
&& !child.definition_background
})
.map(|child| child.request.id.clone()),
)
.collect();
live_ids.sort();
let background_live = self.pending.values().any(|child| {
child.request.parent_session_id == request.parent_session_id
&& child.request.parent_prompt_id.as_deref() == Some(&request.prompt_id)
&& !child.request.owner.is_workflow()
&& child.handle_only
}) || self.active.values().any(|child| {
child.request.parent_session_id == request.parent_session_id
&& child.request.parent_prompt_id.as_deref() == Some(&request.prompt_id)
&& !child.request.owner.is_workflow()
&& (child.handle_only || child.definition_background)
});
let scope =
PromptScope::new(request.parent_session_id.clone(), request.prompt_id.clone());
let _ = request.respond_to.send(SubagentOutstandingReply {
live_ids,
background_live,
subagent_usage_not_applied: self.usage_not_applied_prompts.contains(&scope),
});
}
SubagentEvent::ClearUsageNotApplied(request) => {
self.usage_not_applied_prompts.remove(&PromptScope::new(
request.parent_session_id,
request.prompt_id,
));
}
SubagentEvent::MarkUsageNotApplied(request) => {
self.usage_not_applied_prompts.insert(PromptScope::new(
request.parent_session_id,
request.prompt_id,
));
let _ = request.respond_to.send(());
}
SubagentEvent::RegistryCounts(request) => {
let _ = request.respond_to.send(SubagentRegistryCounts {
pending: self.pending.len(),
active: self.active.len(),
completed: self.completed.len(),
});
}
SubagentEvent::Inspect(request) => {
self.handle_inspect(
request.subagent_id,
request.parent_session_id,
request.respond_to,
);
}
SubagentEvent::SpawnedRefs(request) => {
let mut refs: Vec<_> = self
.active
.values()
.filter(|child| {
child.request.parent_session_id == request.parent_session_id
&& child.request.parent_prompt_id.as_deref() == Some(&request.prompt_id)
})
.map(|child| SpawnedSubagentRef {
subagent_id: child.request.id.clone(),
child_session_id: child.child_session_id.clone(),
subagent_type: child.request.subagent_type.clone(),
description: child.request.description.clone(),
persona: child.persona.clone(),
resumed_from: child.resumed_from.clone(),
})
.chain(
self.completed
.values()
.filter(|child| {
child.request.parent_session_id == request.parent_session_id
&& child.request.parent_prompt_id.as_deref()
== Some(&request.prompt_id)
})
.map(|child| SpawnedSubagentRef {
subagent_id: child.request.id.clone(),
child_session_id: child.child_session_id.clone(),
subagent_type: child.request.subagent_type.clone(),
description: child.request.description.clone(),
persona: child.persona.clone(),
resumed_from: child.resumed_from.clone(),
}),
)
.collect();
refs.sort_by(|a, b| a.subagent_id.cmp(&b.subagent_id));
let _ = request.respond_to.send(refs);
}
SubagentEvent::ValidateType(request) => {
self.validations.push(ReplyFuture {
future: Box::pin(
self.runner
.validate_type(request.subagent_type, request.parent_session_id),
),
respond_to: Some(request.respond_to),
});
}
SubagentEvent::DescribeType(request) => {
self.descriptions.push(ReplyFuture {
future: Box::pin(self.runner.describe_type(
request.subagent_type,
request.harness_agent_type,
request.parent_session_id,
)),
respond_to: Some(request.respond_to),
});
}
SubagentEvent::LoopUnitActive(request) => {
let is_active = self.pending.values().any(|child| {
child.request.runtime_overrides.loop_task_id.as_deref()
== Some(&request.task_id)
}) || self.active.values().any(|child| {
child.request.runtime_overrides.loop_task_id.as_deref()
== Some(&request.task_id)
});
let _ = request.respond_to.send(is_active);
}
}
}
fn handle_internal(&mut self, event: InternalEvent<R::Control>) {
match event {
InternalEvent::Started {
subagent_id,
child,
respond_to,
} => {
let Some(pending) = self.pending.remove(&subagent_id) else {
let _ = respond_to.send(false);
return;
};
if pending.cancellation.is_cancelled() {
self.pending.insert(subagent_id, pending);
let _ = respond_to.send(false);
return;
}
self.active.insert(
subagent_id,
ActiveChild {
request: pending.request,
started_at: pending.started_at,
cancellation: pending.cancellation,
spawn_reply: pending.spawn_reply,
foreground_deadline: pending.foreground_deadline,
handle_only: pending.handle_only,
definition_background: child.definition_background,
explicitly_killed: pending.explicitly_killed,
child_session_id: child.child_session_id,
persona: child.persona,
resumed_from: child.resumed_from,
child_cwd: child.child_cwd,
worktree_path: child.worktree_path,
effective_model_id: child.effective_model_id,
control: child.control,
},
);
let _ = respond_to.send(true);
}
InternalEvent::ResumeSource {
source_id,
parent_session_id,
respond_to,
} => {
let source_is_active =
self.pending
.get(&source_id)
.is_some_and(|child| child.request.parent_session_id == parent_session_id)
|| self.active.get(&source_id).is_some_and(|child| {
child.request.parent_session_id == parent_session_id
});
let lookup = if source_is_active {
SubagentResumeLookup::Active
} else if let Some(child) = self.completed.get(&source_id)
&& child.request.parent_session_id == parent_session_id
{
SubagentResumeLookup::Completed(SubagentResumeSource {
subagent_id: child.request.id.clone(),
child_session_id: child.child_session_id.clone(),
child_cwd: child.child_cwd.clone(),
worktree_path: child.worktree_path.clone(),
snapshot_ref: child.snapshot_ref.clone(),
subagent_type: child.request.subagent_type.clone(),
persona: child.persona.clone(),
model_id: Some(child.effective_model_id.clone()),
})
} else {
SubagentResumeLookup::Missing
};
let _ = respond_to.send(lookup);
}
}
}
fn finish_child(&mut self, id: &str, output: ChildRunOutput<R::CompletionData>) {
let record = if let Some(child) = self.active.remove(id) {
ChildRecord::Active(child)
} else if let Some(child) = self.pending.remove(id) {
ChildRecord::Pending(child)
} else {
return;
};
let request = record.request().clone();
let explicitly_killed = record.explicitly_killed();
let (
started_at,
child_session_id,
persona,
resumed_from,
child_cwd,
worktree_path,
effective_model_id,
mut spawn_reply,
mut handle_only,
) = match record {
ChildRecord::Pending(child) => (
child.started_at,
output.result.child_session_id.clone(),
child.request.runtime_overrides.persona.clone(),
child.request.resume_from.clone(),
child.request.cwd.clone().unwrap_or_default(),
output.result.worktree_path.clone(),
String::new(),
child.spawn_reply,
child.handle_only,
),
ChildRecord::Active(child) => (
child.started_at,
child.child_session_id,
child.persona,
child.resumed_from,
child.child_cwd,
child.worktree_path,
child.effective_model_id,
child.spawn_reply,
child.handle_only,
),
};
let persisted_output_ref = self.runner.persisted_output_ref(&output.completion_data);
let mut completed = CompletedChild {
request: request.clone(),
started_at,
child_session_id,
persona,
resumed_from,
child_cwd,
worktree_path,
snapshot_ref: output.snapshot_ref,
persisted_output_ref,
effective_model_id,
result: output.result.clone(),
};
let snapshot = completed_snapshot(&completed, None);
let mut waiter_delivered = false;
for waiter in self.waiters.remove(id).unwrap_or_default() {
waiter_delivered |= waiter.respond_to.send(Some(snapshot.clone())).is_ok();
}
let mut foreground_delivered = false;
if let Some(respond_to) = spawn_reply.take() {
let sent = respond_to.send(output.result.clone()).is_ok();
if !handle_only {
foreground_delivered = sent;
handle_only = !sent;
}
} else if !handle_only {
handle_only = true;
}
if self.config.buffer_completions
&& request.surface_completion
&& !request.owner.is_workflow()
{
let mut summary = completion_summary(&request, &output.result);
if let Some(cap) = self.config.buffered_completion_output_cap {
summary.output = super::cap_completion_output(&summary.output, cap);
}
self.pending_completions.push(BufferedCompletion {
parent_session_id: request.parent_session_id.clone(),
summary,
});
// Bound the buffer (drop oldest): sessions unloaded without a
// DiscardSessionCompletions cannot grow it unboundedly.
const MAX_PENDING_COMPLETIONS: usize = 256;
if self.pending_completions.len() > MAX_PENDING_COMPLETIONS {
let excess = self.pending_completions.len() - MAX_PENDING_COMPLETIONS;
self.pending_completions.drain(..excess);
}
}
if completed.persisted_output_ref.is_some() {
completed.result.output = Arc::from("");
}
let should_surface = request.surface_completion
&& handle_only
&& !output.result.cancelled
&& !waiter_delivered
&& !explicitly_killed;
let disposition = CompletionDisposition {
foreground_delivered,
backgrounded: handle_only,
waiter_delivered,
explicitly_killed,
should_surface,
};
self.completed.insert(id.to_owned(), completed);
self.completed_order.push_back(id.to_owned());
self.running_count_changed();
let workflow_run_id = request.owner.workflow_run_id().map(str::to_owned);
self.runner.on_completed(ChildCompletion {
request,
result: output.result,
completion_data: output.completion_data,
disposition,
});
if let Some(run_id) = workflow_run_id {
self.resolve_workflow_cancel_waiters(&run_id);
}
}
fn finish_panicked_child(&mut self, id: &str) {
let request = self
.active
.get(id)
.map(|child| child.request.clone())
.or_else(|| self.pending.get(id).map(|child| child.request.clone()));
let Some(request) = request else {
return;
};
tracing::error!(subagent_id = id, "subagent child runner panicked");
self.finish_child(
id,
ChildRunOutput {
result: SubagentResult {
success: false,
error: Some("Subagent runtime panicked".to_owned()),
subagent_id: request.id.clone(),
child_session_id: request.id,
..Default::default()
},
completion_data: R::CompletionData::default(),
snapshot_ref: None,
},
);
}
fn cancel_one(
&mut self,
id: &str,
parent_session_id: Option<&str>,
explicit: bool,
) -> SubagentCancelOutcome {
if let Some(child) = self.active.get_mut(id)
&& belongs_to_session(&child.request, parent_session_id)
{
child.explicitly_killed |= explicit;
child.cancellation.cancel();
child.control.cancel();
return SubagentCancelOutcome::Cancelled;
}
if let Some(child) = self.pending.get_mut(id)
&& belongs_to_session(&child.request, parent_session_id)
{
child.explicitly_killed |= explicit;
child.cancellation.cancel();
return SubagentCancelOutcome::Cancelled;
}
if let Some(child) = self.completed.get(id)
&& belongs_to_session(&child.request, parent_session_id)
{
return SubagentCancelOutcome::AlreadyFinished {
status: child.result.status().to_owned(),
};
}
SubagentCancelOutcome::NotFound
}
fn cancel_parent_prompt(&mut self, parent_prompt_id: &str, parent_session_id: Option<&str>) {
for child in self.active.values() {
if child.request.parent_prompt_id.as_deref() == Some(parent_prompt_id)
&& belongs_to_session(&child.request, parent_session_id)
{
child.cancellation.cancel();
child.control.cancel();
}
}
for child in self.pending.values() {
if child.request.parent_prompt_id.as_deref() == Some(parent_prompt_id)
&& belongs_to_session(&child.request, parent_session_id)
{
child.cancellation.cancel();
}
}
}
fn cancel_workflow_children(&mut self, run_id: &str, parent_session_id: Option<&str>) {
for child in self.active.values() {
if child.request.owner.workflow_run_id() == Some(run_id)
&& belongs_to_session(&child.request, parent_session_id)
{
child.cancellation.cancel();
child.control.cancel();
}
}
for child in self.pending.values() {
if child.request.owner.workflow_run_id() == Some(run_id)
&& belongs_to_session(&child.request, parent_session_id)
{
child.cancellation.cancel();
}
}
}
fn resolve_workflow_cancel_waiters(&mut self, run_id: &str) {
if workflow_outstanding(&self.pending, &self.active, run_id) != 0 {
return;
}
for respond_to in self
.workflow_cancel_waiters
.remove(run_id)
.unwrap_or_default()
{
let _ = respond_to.send(SubagentCancelOutcome::Cancelled);
}
}
fn next_deadline(&self) -> Option<tokio::time::Instant> {
self.pending
.values()
.filter_map(|child| child.foreground_deadline)
.chain(
self.active
.values()
.filter_map(|child| child.foreground_deadline),
)
.chain(
self.waiters
.values()
.flatten()
.map(|waiter| waiter.deadline),
)
.min()
}
fn reap_abandoned_callers(&mut self) {
for child in self.pending.values_mut() {
background_if_caller_gone(child);
}
for child in self.active.values_mut() {
background_if_caller_gone(child);
}
}
fn process_deadlines(&mut self) {
self.reap_abandoned_callers();
let now = tokio::time::Instant::now();
for child in self.pending.values_mut() {
background_at_deadline(child, now, self.config.foreground_budget);
}
for child in self.active.values_mut() {
background_at_deadline(child, now, self.config.foreground_budget);
}
let ids: Vec<_> = self.waiters.keys().cloned().collect();
for id in ids {
let waiters = self.waiters.remove(&id).unwrap_or_default();
let (due, live): (Vec<_>, Vec<_>) = waiters
.into_iter()
.partition(|waiter| waiter.deadline <= now);
if !live.is_empty() {
self.waiters.insert(id.clone(), live);
}
for waiter in due {
if waiter.respond_to.is_closed() {
continue;
}
if self.active.contains_key(&id) {
self.queue_active_progress(&id, ProgressTarget::Query(waiter.respond_to));
} else {
let _ = waiter.respond_to.send(self.ready_snapshot(&id));
}
}
}
}
fn running_count_changed(&self) {
self.runner
.running_count_changed(self.pending.len() + self.active.len());
}
fn cancel_all_children(&self) {
for child in self.active.values() {
child.cancellation.cancel();
child.control.cancel();
}
for child in self.pending.values() {
child.cancellation.cancel();
}
}
}
fn belongs_to_session(request: &SubagentRequest, parent_session_id: Option<&str>) -> bool {
parent_session_id.is_none_or(|id| request.parent_session_id == id)
}
impl<R: ChildRunner> Drop for SubagentCoordinator<R> {
fn drop(&mut self) {
self.cancel_all_children();
}
}
#[cfg(test)]
#[path = "coordinator_tests.rs"]
mod tests;

View file

@ -0,0 +1,256 @@
//! Session-scoped query, inspection, and progress delivery.
use std::sync::Arc;
use tokio::sync::oneshot;
use super::super::coordinator_state::{
BlockingWaiter, CompletedChild, ListRequest, OUTPUT_UNAVAILABLE_PLACEHOLDER, ProgressFuture,
ProgressTarget, RunningSeed, completed_inspection, completed_snapshot, pending_inspection,
pending_snapshot, running_inspection, running_seed,
};
use super::super::types::{SubagentInspection, SubagentSnapshot};
use super::{ChildControl, ChildRunner, SubagentCoordinator, SubagentProgress, belongs_to_session};
impl<R: ChildRunner> SubagentCoordinator<R> {
pub(super) fn handle_query(
&mut self,
id: String,
parent_session_id: Option<String>,
block: bool,
timeout_ms: Option<u64>,
respond_to: oneshot::Sender<Option<SubagentSnapshot>>,
) {
if let Some(child) = self
.completed
.get(&id)
.filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref()))
{
let snapshot = (!child.request.owner.is_workflow())
.then(|| self.completed_snapshot_for_query(child));
let _ = respond_to.send(snapshot);
return;
}
if let Some(child) = self
.active
.get(&id)
.filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref()))
{
if child.request.owner.is_workflow() {
let _ = respond_to.send(None);
return;
}
if block {
self.waiters.entry(id).or_default().push(BlockingWaiter {
deadline: tokio::time::Instant::now()
+ std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)),
respond_to,
});
} else {
self.queue_active_progress(&id, ProgressTarget::Query(respond_to));
}
return;
}
if let Some(child) = self
.pending
.get(&id)
.filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref()))
{
if child.request.owner.is_workflow() {
let _ = respond_to.send(None);
return;
}
if block {
self.waiters.entry(id).or_default().push(BlockingWaiter {
deadline: tokio::time::Instant::now()
+ std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)),
respond_to,
});
} else {
let _ = respond_to.send(Some(pending_snapshot(child)));
}
return;
}
let _ = respond_to.send(None);
}
pub(super) fn handle_inspect(
&mut self,
id: String,
parent_session_id: Option<String>,
respond_to: oneshot::Sender<Option<SubagentInspection>>,
) {
if let Some(child) = self
.completed
.get(&id)
.filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref()))
{
let _ = respond_to.send(Some(self.completed_inspection_for_query(child)));
} else if let Some(child) = self
.pending
.get(&id)
.filter(|child| belongs_to_session(&child.request, parent_session_id.as_deref()))
{
let _ = respond_to.send(Some(pending_inspection(child)));
} else if self
.active
.get(&id)
.is_some_and(|child| belongs_to_session(&child.request, parent_session_id.as_deref()))
{
self.queue_active_progress(&id, ProgressTarget::Inspect(respond_to));
} else {
let _ = respond_to.send(None);
}
}
fn persisted_output(&self, child: &CompletedChild) -> Option<Arc<str>> {
child.persisted_output_ref.as_deref().map(|reference| {
self.runner
.load_persisted_output(reference)
.unwrap_or_else(|| Arc::from(OUTPUT_UNAVAILABLE_PLACEHOLDER))
})
}
fn completed_snapshot_for_query(&self, child: &CompletedChild) -> SubagentSnapshot {
let output = self.persisted_output(child);
completed_snapshot(child, output.as_deref())
}
fn completed_inspection_for_query(&self, child: &CompletedChild) -> SubagentInspection {
let output = self.persisted_output(child);
completed_inspection(child, output.as_deref())
}
pub(super) fn ready_snapshot(&self, id: &str) -> Option<SubagentSnapshot> {
self.completed
.get(id)
.filter(|child| !child.request.owner.is_workflow())
.map(|child| self.completed_snapshot_for_query(child))
.or_else(|| {
self.pending
.get(id)
.filter(|child| !child.request.owner.is_workflow())
.map(pending_snapshot)
})
}
pub(super) fn handle_list_running(
&mut self,
parent_session_id: String,
respond_to: oneshot::Sender<Vec<SubagentInspection>>,
) {
let ids: Vec<_> = self
.active
.values()
.filter(|child| {
child.request.parent_session_id == parent_session_id
&& !child.request.owner.is_workflow()
})
.map(|child| child.request.id.clone())
.collect();
if ids.is_empty() {
let _ = respond_to.send(Vec::new());
return;
}
let request_id = self.next_list_request_id;
self.next_list_request_id = self.next_list_request_id.wrapping_add(1);
self.list_requests.insert(
request_id,
ListRequest {
slots: vec![None; ids.len()],
remaining: ids.len(),
respond_to,
},
);
for (index, id) in ids.into_iter().enumerate() {
self.queue_active_progress(&id, ProgressTarget::List { request_id, index });
}
}
pub(super) fn queue_active_progress(&mut self, id: &str, target: ProgressTarget) {
let Some(child) = self.active.get(id) else {
match target {
ProgressTarget::Query(tx) => {
let _ = tx.send(self.ready_snapshot(id));
}
ProgressTarget::Inspect(tx) => {
let value = self
.completed
.get(id)
.map(|child| self.completed_inspection_for_query(child));
let _ = tx.send(value);
}
ProgressTarget::List { request_id, index } => {
self.finish_list_slot(request_id, index, None);
}
}
return;
};
self.progress.push(ProgressFuture {
future: Box::pin(child.control.progress()),
seed: Some(running_seed(child)),
target: Some(target),
});
}
pub(super) fn finish_progress(
&mut self,
seed: RunningSeed,
target: ProgressTarget,
progress: SubagentProgress,
) {
let still_active = self.active.contains_key(&seed.subagent_id);
if !still_active {
match target {
ProgressTarget::Query(respond_to) => {
let _ = respond_to.send(self.ready_snapshot(&seed.subagent_id));
}
ProgressTarget::Inspect(respond_to) => {
let value = self
.completed
.get(&seed.subagent_id)
.map(|child| self.completed_inspection_for_query(child));
let _ = respond_to.send(value);
}
ProgressTarget::List { request_id, index } => {
self.finish_list_slot(request_id, index, None);
}
}
return;
}
let inspection = running_inspection(seed, progress);
match target {
ProgressTarget::Query(respond_to) => {
let _ = respond_to.send(Some(inspection.snapshot));
}
ProgressTarget::Inspect(respond_to) => {
let _ = respond_to.send(Some(inspection));
}
ProgressTarget::List { request_id, index } => {
self.finish_list_slot(request_id, index, Some(inspection));
}
}
}
fn finish_list_slot(
&mut self,
request_id: u64,
index: usize,
inspection: Option<SubagentInspection>,
) {
let Some(request) = self.list_requests.get_mut(&request_id) else {
return;
};
request.slots[index] = inspection;
request.remaining = request.remaining.saturating_sub(1);
if request.remaining != 0 {
return;
}
let Some(request) = self.list_requests.remove(&request_id) else {
return;
};
let values = request.slots.into_iter().flatten().collect();
let _ = request.respond_to.send(values);
}
}

View file

@ -0,0 +1,731 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use super::types::{
ActiveSubagentSummary, SubagentCompletionSummary, SubagentDescribeOutcome, SubagentInspection,
SubagentRequest, SubagentResult, SubagentResumeLookup, SubagentSnapshot,
SubagentSnapshotStatus, SubagentValidateTypeOutcome,
};
pub(super) const MAX_COMPLETED_ENTRIES: usize = 1024;
pub(super) const OUTPUT_UNAVAILABLE_PLACEHOLDER: &str = "[subagent output no longer available]";
pub type LocalBoxFuture<T> = Pin<Box<dyn Future<Output = T> + 'static>>;
pub type SendBoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
/// Runtime-specific live progress for one active child.
#[derive(Debug, Clone, Default)]
pub struct SubagentProgress {
pub turn_count: u32,
pub tool_call_count: u32,
pub tokens_used: u64,
pub context_window_tokens: u64,
pub context_usage_pct: u8,
pub tools_used: Vec<String>,
pub error_count: u32,
}
/// Runtime handle retained while a child is active.
pub trait ChildControl: 'static {
type ProgressFuture: Future<Output = SubagentProgress> + 'static;
fn progress(&self) -> Self::ProgressFuture;
fn cancel(&self);
}
/// Data reported when runtime initialization has produced a live child.
pub struct StartedChild<C> {
pub child_session_id: String,
pub persona: Option<String>,
pub resumed_from: Option<String>,
pub child_cwd: String,
pub worktree_path: Option<String>,
pub effective_model_id: String,
/// The resolved agent definition declares `background: true`. Folded into
/// `Outstanding` accounting (background, never turn-blocking) while the
/// foreground await budget stays gated on the tool's own
/// `run_in_background` flag.
pub definition_background: bool,
pub control: C,
}
/// Input to one runtime-specific child run.
pub struct ChildRunRequest<C> {
pub request: SubagentRequest,
pub cancellation: CancellationToken,
pub reporter: ChildReporter<C>,
}
/// Terminal output from one runtime-specific child run.
pub struct ChildRunOutput<D> {
pub result: SubagentResult,
pub completion_data: D,
pub snapshot_ref: Option<String>,
}
/// Coordinator-owned delivery decision passed to host presentation.
#[derive(Debug, Clone)]
pub struct CompletionDisposition {
pub foreground_delivered: bool,
pub backgrounded: bool,
pub waiter_delivered: bool,
pub explicitly_killed: bool,
pub should_surface: bool,
}
/// Terminal event delivered to the runtime adapter after state is committed.
pub struct ChildCompletion<D> {
pub request: SubagentRequest,
pub result: SubagentResult,
pub completion_data: D,
pub disposition: CompletionDisposition,
}
/// The only host-specific seam.
///
/// Associated future types intentionally carry no unconditional `Send` bound.
/// A local runner may return non-`Send` futures, while a multithreaded runner
/// may return `Send` futures.
pub trait ChildRunner: 'static {
type Control: ChildControl;
type CompletionData: Default + 'static;
type RunFuture: Future<Output = ChildRunOutput<Self::CompletionData>> + 'static;
type ValidateFuture: Future<Output = SubagentValidateTypeOutcome> + 'static;
type DescribeFuture: Future<Output = SubagentDescribeOutcome> + 'static;
fn run(&self, request: ChildRunRequest<Self::Control>) -> Self::RunFuture;
fn validate_type(
&self,
subagent_type: String,
parent_session_id: String,
) -> Self::ValidateFuture;
fn describe_type(
&self,
subagent_type: String,
harness_agent_type: Option<String>,
parent_session_id: String,
) -> Self::DescribeFuture;
fn on_completed(&self, completion: ChildCompletion<Self::CompletionData>);
fn running_count_changed(&self, _running: usize) {}
fn persisted_output_ref(&self, _completion_data: &Self::CompletionData) -> Option<String> {
None
}
fn load_persisted_output(&self, _reference: &str) -> Option<Arc<str>> {
None
}
}
/// Host-configurable lifecycle policy. The transition logic remains shared.
#[derive(Debug, Clone)]
pub struct CoordinatorConfig {
pub foreground_budget: std::time::Duration,
/// Whether the host drains completion summaries between turns.
pub buffer_completions: bool,
/// Extra cap applied to BUFFERED summary outputs only (the request's own
/// `completion_output_cap` still applies first). Buffered entries pin the
/// child's output `Arc` until drained; hosts whose reminder rendering
/// never inlines the output (a polling tool exists, e.g. the callback
/// tools-server) should bound it. `None` keeps outputs verbatim — the
/// shell needs this for toolsets with no polling tool, where the inline
/// reminder is the model's only chance to see the output.
pub buffered_completion_output_cap: Option<usize>,
}
impl Default for CoordinatorConfig {
fn default() -> Self {
Self {
foreground_budget: std::time::Duration::from_secs(45),
buffer_completions: false,
buffered_completion_output_cap: None,
}
}
}
/// Runner-side channel back into the actor.
pub struct ChildReporter<C> {
pub(super) subagent_id: String,
pub(super) tx: mpsc::UnboundedSender<InternalEvent<C>>,
}
impl<C> Clone for ChildReporter<C> {
fn clone(&self) -> Self {
Self {
subagent_id: self.subagent_id.clone(),
tx: self.tx.clone(),
}
}
}
impl<C: 'static> ChildReporter<C> {
/// Promote the pending child to active. The acknowledgement closes the
/// cancel-at-promote race: `false` means cancellation won and the adapter
/// must tear down the half-initialized runtime.
pub async fn started(&self, child: StartedChild<C>) -> bool {
let (respond_to, response_rx) = oneshot::channel();
if self
.tx
.send(InternalEvent::Started {
subagent_id: self.subagent_id.clone(),
child,
respond_to,
})
.is_err()
{
return false;
}
response_rx.await.unwrap_or(false)
}
/// Resolve an in-memory resume source without sharing coordinator state.
pub async fn resume_source(
&self,
source_id: &str,
parent_session_id: &str,
) -> SubagentResumeLookup {
let (respond_to, response_rx) = oneshot::channel();
if self
.tx
.send(InternalEvent::ResumeSource {
source_id: source_id.to_owned(),
parent_session_id: parent_session_id.to_owned(),
respond_to,
})
.is_err()
{
return SubagentResumeLookup::Missing;
}
response_rx.await.unwrap_or(SubagentResumeLookup::Missing)
}
}
pub(super) enum InternalEvent<C> {
Started {
subagent_id: String,
child: StartedChild<C>,
respond_to: oneshot::Sender<bool>,
},
ResumeSource {
source_id: String,
parent_session_id: String,
respond_to: oneshot::Sender<SubagentResumeLookup>,
},
}
pub(super) struct PendingChild {
pub(super) request: SubagentRequest,
pub(super) started_at: std::time::Instant,
pub(super) cancellation: CancellationToken,
pub(super) spawn_reply: Option<oneshot::Sender<SubagentResult>>,
pub(super) foreground_deadline: Option<tokio::time::Instant>,
pub(super) handle_only: bool,
pub(super) explicitly_killed: bool,
}
pub(super) struct ActiveChild<C> {
pub(super) request: SubagentRequest,
pub(super) started_at: std::time::Instant,
pub(super) cancellation: CancellationToken,
pub(super) spawn_reply: Option<oneshot::Sender<SubagentResult>>,
pub(super) foreground_deadline: Option<tokio::time::Instant>,
pub(super) handle_only: bool,
/// Definition-declared background (see [`StartedChild`]): background for
/// `Outstanding` accounting even while the spawn caller block-awaits.
pub(super) definition_background: bool,
pub(super) explicitly_killed: bool,
pub(super) child_session_id: String,
pub(super) persona: Option<String>,
pub(super) resumed_from: Option<String>,
pub(super) child_cwd: String,
pub(super) worktree_path: Option<String>,
pub(super) effective_model_id: String,
pub(super) control: C,
}
pub(super) struct CompletedChild {
pub(super) request: SubagentRequest,
pub(super) started_at: std::time::Instant,
pub(super) child_session_id: String,
pub(super) persona: Option<String>,
pub(super) resumed_from: Option<String>,
pub(super) child_cwd: String,
pub(super) worktree_path: Option<String>,
pub(super) snapshot_ref: Option<String>,
pub(super) persisted_output_ref: Option<String>,
pub(super) effective_model_id: String,
pub(super) result: SubagentResult,
}
pub(super) struct BlockingWaiter {
pub(super) deadline: tokio::time::Instant,
pub(super) respond_to: oneshot::Sender<Option<SubagentSnapshot>>,
}
pub(super) struct BufferedCompletion {
pub(super) parent_session_id: String,
pub(super) summary: SubagentCompletionSummary,
}
pub(super) struct TaggedFuture<F> {
pub(super) subagent_id: String,
pub(super) future: Pin<Box<F>>,
}
impl<F: Future> Future for TaggedFuture<F> {
type Output = (String, F::Output);
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
this.future
.as_mut()
.poll(cx)
.map(|output| (this.subagent_id.clone(), output))
}
}
pub(super) struct ReplyFuture<F, T> {
pub(super) future: Pin<Box<F>>,
pub(super) respond_to: Option<oneshot::Sender<T>>,
}
impl<F, T> Future for ReplyFuture<F, T>
where
F: Future<Output = T>,
{
type Output = (oneshot::Sender<T>, T);
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
this.future.as_mut().poll(cx).map(|output| {
let respond_to = match this.respond_to.take() {
Some(respond_to) => respond_to,
None => unreachable!("reply future polled after completion"),
};
(respond_to, output)
})
}
}
#[derive(Clone)]
pub(super) struct RunningSeed {
pub(super) subagent_id: String,
pub(super) description: String,
pub(super) subagent_type: String,
pub(super) started_at_epoch_ms: u64,
pub(super) duration_ms: u64,
pub(super) persona: Option<String>,
pub(super) parent_session_id: String,
pub(super) child_session_id: String,
pub(super) fork_parent_prompt_id: Option<String>,
pub(super) resumed_from: Option<String>,
}
pub(super) enum ProgressTarget {
Query(oneshot::Sender<Option<SubagentSnapshot>>),
Inspect(oneshot::Sender<Option<SubagentInspection>>),
List { request_id: u64, index: usize },
}
pub(super) struct ProgressFuture<F> {
pub(super) future: Pin<Box<F>>,
pub(super) seed: Option<RunningSeed>,
pub(super) target: Option<ProgressTarget>,
}
impl<F> Future for ProgressFuture<F>
where
F: Future<Output = SubagentProgress>,
{
type Output = (RunningSeed, ProgressTarget, SubagentProgress);
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
this.future.as_mut().poll(cx).map(|progress| {
let seed = match this.seed.take() {
Some(seed) => seed,
None => unreachable!("progress future polled without a seed"),
};
let target = match this.target.take() {
Some(target) => target,
None => unreachable!("progress future polled without a target"),
};
(seed, target, progress)
})
}
}
pub(super) struct ListRequest {
pub(super) slots: Vec<Option<SubagentInspection>>,
pub(super) remaining: usize,
pub(super) respond_to: oneshot::Sender<Vec<SubagentInspection>>,
}
pub(super) enum ChildRecord<C> {
Pending(PendingChild),
Active(ActiveChild<C>),
}
impl<C> ChildRecord<C> {
pub(super) fn request(&self) -> &SubagentRequest {
match self {
Self::Pending(child) => &child.request,
Self::Active(child) => &child.request,
}
}
pub(super) fn explicitly_killed(&self) -> bool {
match self {
Self::Pending(child) => child.explicitly_killed,
Self::Active(child) => child.explicitly_killed,
}
}
}
pub(super) trait ForegroundChild {
fn id(&self) -> &str;
fn child_session_id(&self) -> &str;
fn deadline(&self) -> Option<tokio::time::Instant>;
/// True when the spawn caller dropped its result receiver while this
/// child was still treated as turn-blocking (old shell `ParentGone`).
fn caller_gone(&self) -> bool;
fn is_workflow(&self) -> bool;
fn take_reply(&mut self) -> Option<oneshot::Sender<SubagentResult>>;
fn mark_backgrounded(&mut self);
/// Cancel the child's execution (token + active control where present).
fn cancel(&mut self);
}
impl ForegroundChild for PendingChild {
fn id(&self) -> &str {
&self.request.id
}
fn child_session_id(&self) -> &str {
&self.request.id
}
fn deadline(&self) -> Option<tokio::time::Instant> {
self.foreground_deadline
}
fn caller_gone(&self) -> bool {
!self.handle_only && self.spawn_reply.as_ref().is_some_and(|tx| tx.is_closed())
}
fn is_workflow(&self) -> bool {
self.request.owner.is_workflow()
}
fn take_reply(&mut self) -> Option<oneshot::Sender<SubagentResult>> {
self.spawn_reply.take()
}
fn mark_backgrounded(&mut self) {
self.handle_only = true;
self.foreground_deadline = None;
}
fn cancel(&mut self) {
self.cancellation.cancel();
}
}
impl<C: ChildControl> ForegroundChild for ActiveChild<C> {
fn id(&self) -> &str {
&self.request.id
}
fn child_session_id(&self) -> &str {
&self.child_session_id
}
fn deadline(&self) -> Option<tokio::time::Instant> {
self.foreground_deadline
}
fn caller_gone(&self) -> bool {
!self.handle_only && self.spawn_reply.as_ref().is_some_and(|tx| tx.is_closed())
}
fn is_workflow(&self) -> bool {
self.request.owner.is_workflow()
}
fn take_reply(&mut self) -> Option<oneshot::Sender<SubagentResult>> {
self.spawn_reply.take()
}
fn mark_backgrounded(&mut self) {
self.handle_only = true;
self.foreground_deadline = None;
}
fn cancel(&mut self) {
self.cancellation.cancel();
self.control.cancel();
}
}
pub(super) fn background_at_deadline(
child: &mut impl ForegroundChild,
now: tokio::time::Instant,
budget: std::time::Duration,
) {
if child.deadline().is_none_or(|deadline| deadline > now) {
return;
}
tracing::warn!(
subagent_id = child.id(),
budget_ms = budget.as_millis() as u64,
"foreground subagent exceeded await budget; auto-backgrounding (child keeps running)",
);
if let Some(respond_to) = child.take_reply() {
// Interim handoff, not a completion: keep `success: false` (default)
// so `SubagentResult::status()` consumers cannot record a completed
// status for a still-running child. Callers branch on `backgrounded`.
let _ = respond_to.send(SubagentResult {
backgrounded: true,
subagent_id: child.id().to_owned(),
child_session_id: child.child_session_id().to_owned(),
..Default::default()
});
}
child.mark_backgrounded();
}
/// Handle a foreground child whose spawn caller dropped the result channel
/// (parent turn stop / cancelled await). Task-owned children keep running and
/// just leave the turn-blocking `Outstanding` set — shell `ParentGone` parity.
/// Workflow-owned children are CANCELLED instead (old shell `ParentGone`
/// cancelled workflow children); `ChannelBackend`'s drop-cancel arming remains
/// defense in depth for hosts that go through it.
pub(super) fn background_if_caller_gone(child: &mut impl ForegroundChild) {
if !child.caller_gone() {
return;
}
let _ = child.take_reply();
if child.is_workflow() {
tracing::debug!(
subagent_id = child.id(),
"workflow subagent caller gone; cancelling child",
);
child.cancel();
return;
}
tracing::debug!(
subagent_id = child.id(),
"foreground subagent caller gone; auto-backgrounding (child keeps running)",
);
child.mark_backgrounded();
}
pub(super) async fn sleep_until(deadline: Option<tokio::time::Instant>) {
match deadline {
Some(deadline) => tokio::time::sleep_until(deadline).await,
None => std::future::pending().await,
}
}
fn instant_to_epoch_ms(instant: std::time::Instant) -> u64 {
let now_instant = std::time::Instant::now();
let now_system = std::time::SystemTime::now();
let elapsed = now_instant.saturating_duration_since(instant);
now_system
.checked_sub(elapsed)
.unwrap_or(now_system)
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub(super) fn active_summary<C>(child: &ActiveChild<C>) -> ActiveSubagentSummary {
ActiveSubagentSummary {
subagent_id: child.request.id.clone(),
subagent_type: child.request.subagent_type.clone(),
description: child.request.description.clone(),
elapsed_ms: child.started_at.elapsed().as_millis() as u64,
}
}
pub(super) fn running_seed<C>(child: &ActiveChild<C>) -> RunningSeed {
RunningSeed {
subagent_id: child.request.id.clone(),
description: child.request.description.clone(),
subagent_type: child.request.subagent_type.clone(),
started_at_epoch_ms: instant_to_epoch_ms(child.started_at),
duration_ms: child.started_at.elapsed().as_millis() as u64,
persona: child.persona.clone(),
parent_session_id: child.request.parent_session_id.clone(),
child_session_id: child.child_session_id.clone(),
fork_parent_prompt_id: child.request.parent_prompt_id.clone(),
resumed_from: child.resumed_from.clone(),
}
}
pub(super) fn running_inspection(
seed: RunningSeed,
progress: SubagentProgress,
) -> SubagentInspection {
SubagentInspection {
snapshot: SubagentSnapshot {
subagent_id: seed.subagent_id,
description: seed.description,
subagent_type: seed.subagent_type,
status: SubagentSnapshotStatus::Running {
turn_count: progress.turn_count,
tool_call_count: progress.tool_call_count,
tokens_used: progress.tokens_used,
context_window_tokens: progress.context_window_tokens,
context_usage_pct: progress.context_usage_pct,
tools_used: progress.tools_used,
error_count: progress.error_count,
},
started_at_epoch_ms: seed.started_at_epoch_ms,
duration_ms: seed.duration_ms,
persona: seed.persona,
},
parent_session_id: seed.parent_session_id,
child_session_id: seed.child_session_id,
fork_parent_prompt_id: seed.fork_parent_prompt_id,
resumed_from: seed.resumed_from,
}
}
pub(super) fn pending_snapshot(child: &PendingChild) -> SubagentSnapshot {
SubagentSnapshot {
subagent_id: child.request.id.clone(),
description: child.request.description.clone(),
subagent_type: child.request.subagent_type.clone(),
status: SubagentSnapshotStatus::Initializing,
started_at_epoch_ms: instant_to_epoch_ms(child.started_at),
duration_ms: child.started_at.elapsed().as_millis() as u64,
persona: child.request.runtime_overrides.persona.clone(),
}
}
pub(super) fn pending_inspection(child: &PendingChild) -> SubagentInspection {
SubagentInspection {
snapshot: pending_snapshot(child),
parent_session_id: child.request.parent_session_id.clone(),
child_session_id: String::new(),
fork_parent_prompt_id: child.request.parent_prompt_id.clone(),
resumed_from: child.request.resume_from.clone(),
}
}
pub(super) fn completed_snapshot(
child: &CompletedChild,
persisted_output: Option<&str>,
) -> SubagentSnapshot {
let status = if child.result.cancelled {
SubagentSnapshotStatus::Cancelled {
reason: child.result.error.clone(),
}
} else if child.result.success {
SubagentSnapshotStatus::Completed {
output: persisted_output
.map(str::to_owned)
.unwrap_or_else(|| child.result.output.to_string()),
tool_calls: child.result.tool_calls,
turns: child.result.turns,
worktree_path: child.result.worktree_path.clone(),
}
} else {
SubagentSnapshotStatus::Failed {
error: child
.result
.error
.clone()
.unwrap_or_else(|| "Unknown error".to_owned()),
}
};
SubagentSnapshot {
subagent_id: child.request.id.clone(),
description: child.request.description.clone(),
subagent_type: child.request.subagent_type.clone(),
status,
started_at_epoch_ms: instant_to_epoch_ms(child.started_at),
duration_ms: child.result.duration_ms,
persona: child.persona.clone(),
}
}
pub(super) fn completed_inspection(
child: &CompletedChild,
persisted_output: Option<&str>,
) -> SubagentInspection {
SubagentInspection {
snapshot: completed_snapshot(child, persisted_output),
parent_session_id: child.request.parent_session_id.clone(),
child_session_id: child.child_session_id.clone(),
fork_parent_prompt_id: child.request.parent_prompt_id.clone(),
resumed_from: child.resumed_from.clone(),
}
}
/// Truncate `output` to `cap` bytes (UTF-8 safe) with a truncation footer.
/// Returns a refcount clone when already within the cap.
pub fn cap_completion_output(output: &Arc<str>, cap: usize) -> Arc<str> {
if output.len() <= cap {
return output.clone();
}
let mut end = cap;
while end > 0 && !output.is_char_boundary(end) {
end -= 1;
}
Arc::from(format!(
"{}\n[output truncated: {} of {} bytes shown]",
&output[..end],
end,
output.len()
))
}
/// Model-facing summary for a finished child, honoring the request's
/// `completion_output_cap`. Shared by the coordinator's buffered reminder
/// path and the shell's auto-wake synthetic prompt.
pub fn completion_summary(
request: &SubagentRequest,
result: &SubagentResult,
) -> SubagentCompletionSummary {
let output = match request.runtime_overrides.completion_output_cap {
Some(cap) => cap_completion_output(&result.output, cap),
None => result.output.clone(),
};
SubagentCompletionSummary {
subagent_id: request.id.clone(),
subagent_type: request.subagent_type.clone(),
description: request.description.clone(),
success: result.success && !result.cancelled,
duration_ms: result.duration_ms,
tool_calls: result.tool_calls,
turns: result.turns,
output,
}
}
pub(super) fn workflow_outstanding<C>(
pending: &HashMap<String, PendingChild>,
active: &HashMap<String, ActiveChild<C>>,
run_id: &str,
) -> usize {
pending
.values()
.filter(|child| child.request.owner.workflow_run_id() == Some(run_id))
.count()
+ active
.values()
.filter(|child| child.request.owner.workflow_run_id() == Some(run_id))
.count()
}

View file

@ -2,17 +2,21 @@
//!
//! The TaskTool delegates subagent operations to a [`SubagentBackend`]
//! (injected as [`SubagentBackendResource`]). The backend abstracts over the
//! transport mechanism (in-process channels for the local host, remote
//! backends, etc.).
//! coordinator mailbox. All hosts use the same backend and coordinator actor;
//! only their child runners differ.
//!
//! ## Resources
//!
//! - `SubagentBackendResource` — backend for spawn/query/cancel (required)
//! - `SubagentDepthCounter` — current nesting depth (optional, defaults to 0)
//! - `SessionIdResource` — current session ID for parent scoping (optional)
//! - `SubagentForegroundWait` — host wait-window guard factory (optional)
//! - `TaskModelValidator` — validates explicit model slugs before spawn
pub mod backend;
pub mod coordinator;
mod coordinator_state;
pub use coordinator_state::{cap_completion_output, completion_summary};
pub mod types;
use self::backend::SubagentBackendResource;
@ -116,9 +120,12 @@ impl xai_tool_runtime::Tool for TaskTool {
) -> Result<ToolOutput, xai_tool_runtime::ToolError> {
use crate::types::tool_metadata::shared_resources;
let resources = shared_resources(&ctx)?;
let tool_cancellation = ctx
.get::<xai_tool_runtime::Cancellation>()
.map(|cancellation| cancellation.0.clone());
// 1. Depth check
let (depth, backend, model_validator, parent_session_id, parent_prompt_id) = {
let (depth, backend, model_validator, parent_session_id, parent_prompt_id, foreground_wait) = {
let res = resources.lock().await;
let depth = res.get::<SubagentDepthCounter>().map(|d| d.0).unwrap_or(0);
@ -144,6 +151,7 @@ impl xai_tool_runtime::Tool for TaskTool {
.get::<CurrentPromptIdResource>()
.map(|p| p.0.clone())
.filter(|prompt_id| !prompt_id.is_empty());
let foreground_wait = res.get::<SubagentForegroundWait>().cloned();
(
depth,
@ -151,6 +159,7 @@ impl xai_tool_runtime::Tool for TaskTool {
model_validator,
parent_session_id,
parent_prompt_id,
foreground_wait,
)
};
@ -289,9 +298,18 @@ impl xai_tool_runtime::Tool for TaskTool {
.task_id
.clone()
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
// Placeholder; `ChannelBackend::spawn` replaces it with a fresh one.
let (result_tx, _) = tokio::sync::oneshot::channel();
let child_cancellation = tokio_util::sync::CancellationToken::new();
let cancellation_forwarder = (!input.run_in_background)
.then(|| {
tool_cancellation.map(|tool_cancellation| {
let child_cancellation = child_cancellation.clone();
tokio::spawn(async move {
tool_cancellation.cancelled().await;
child_cancellation.cancel();
})
})
})
.flatten();
let request = SubagentRequest {
id: id.clone(),
@ -325,8 +343,7 @@ impl xai_tool_runtime::Tool for TaskTool {
await_to_completion: false,
fork_context: false,
owner: SubagentOwner::Task,
cancel_token: tokio_util::sync::CancellationToken::new(),
result_tx,
cancel_token: child_cancellation,
};
// 4. Background mode: fire-and-forget via backend.spawn().
@ -377,7 +394,12 @@ impl xai_tool_runtime::Tool for TaskTool {
}
// 5. Blocking mode (default): spawn via backend and await result
let result = backend.backend().spawn(request).await?;
let _foreground_wait = foreground_wait.map(|wait| wait.enter());
let result = backend.backend().spawn(request).await;
if let Some(forwarder) = cancellation_forwarder {
forwarder.abort();
}
let result = result?;
// 5b. The await budget expired and the coordinator auto-backgrounded the
// still-running child — return a task_id to poll, like the background
@ -495,10 +517,10 @@ mod tests {
(backend, proxy_rx)
}
/// Extract a `SubagentRequest` from a `SubagentEvent`, panicking on wrong variant.
fn unwrap_spawn(event: SubagentEvent) -> SubagentRequest {
/// Extract a spawn envelope from a `SubagentEvent`.
fn unwrap_spawn(event: SubagentEvent) -> SubagentSpawnRequest {
match event {
SubagentEvent::Spawn(r) => *r,
SubagentEvent::Spawn(r) => r,
_ => panic!("Expected SubagentEvent::Spawn"),
}
}
@ -621,8 +643,7 @@ mod tests {
assert_eq!(request.parent_session_id, "parent-session");
assert_eq!(request.parent_prompt_id.as_deref(), Some("prompt-123"));
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: std::sync::Arc::from("Found 3 auth middleware files"),
subagent_id: request.id.clone(),
@ -683,8 +704,7 @@ mod tests {
let handle = tokio::spawn(async move {
let request = unwrap_spawn(rx.recv().await.unwrap());
request
.result_tx
.send(SubagentResult {
.respond_with(|_| SubagentResult {
success: false,
error: Some("Child session crashed".to_string()),
..Default::default()
@ -765,11 +785,22 @@ mod tests {
#[tokio::test]
async fn auto_backgrounded_result_returns_task_id_text() {
let (backend, mut rx) = make_backend();
let resources = resources_for_task(backend);
let mut resources = resources_for_task(backend);
let wait_closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
struct WaitProbe(Arc<std::sync::atomic::AtomicBool>);
impl Drop for WaitProbe {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
let wait_closed_for_factory = Arc::clone(&wait_closed);
resources.insert(SubagentForegroundWait::new(move || {
Box::new(WaitProbe(Arc::clone(&wait_closed_for_factory)))
}));
let drain = tokio::spawn(async move {
if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await {
let _ = boxed.result_tx.send(SubagentResult {
let _ = boxed.respond_with(|boxed| SubagentResult {
backgrounded: true,
subagent_id: boxed.id.clone(),
child_session_id: boxed.id.clone(),
@ -785,6 +816,10 @@ mod tests {
)
.await
.expect("auto-backgrounded blocking spawn returns Ok");
assert!(
wait_closed.load(std::sync::atomic::Ordering::Relaxed),
"auto-backgrounding must close the foreground wait window"
);
match result {
ToolOutput::Text(text) => {
@ -982,7 +1017,7 @@ mod tests {
let drain = tokio::spawn(async move {
if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await {
let _ = boxed.result_tx.send(SubagentResult {
let _ = boxed.respond_with(|boxed| SubagentResult {
success: true,
output: std::sync::Arc::from(""),
subagent_id: boxed.id.clone(),
@ -1023,7 +1058,7 @@ mod tests {
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
let drain = tokio::spawn(async move {
if let Some(SubagentEvent::Spawn(boxed)) = rx.recv().await {
let _ = boxed.result_tx.send(SubagentResult {
let _ = boxed.respond_with(|boxed| SubagentResult {
success: false,
error: Some("worktree creation failed".to_string()),
subagent_id: boxed.id.clone(),
@ -1502,8 +1537,7 @@ mod tests {
"model-spawned task must not set fork_context"
);
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
@ -1585,8 +1619,7 @@ mod tests {
let request = unwrap_spawn(rx.recv().await.unwrap());
assert_eq!(request.resume_from.as_deref(), Some("prev-id"));
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "resumed".into(),
subagent_id: request.id.clone(),
@ -1652,8 +1685,7 @@ mod tests {
request.resume_from
);
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "fresh".into(),
subagent_id: request.id.clone(),
@ -1781,8 +1813,7 @@ mod tests {
request.cwd
);
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
@ -1832,8 +1863,7 @@ mod tests {
request.cwd
);
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
@ -1883,8 +1913,7 @@ mod tests {
request.cwd
);
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
@ -1937,8 +1966,7 @@ mod tests {
request.cwd
);
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
@ -2027,8 +2055,7 @@ mod tests {
request.cwd
);
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
@ -2081,8 +2108,7 @@ mod tests {
let request = unwrap_spawn(rx.recv().await.unwrap());
assert_eq!(request.cwd.as_deref(), Some("/tmp"));
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "done".into(),
subagent_id: request.id.clone(),
@ -2139,8 +2165,7 @@ mod tests {
"stray leading quote should be stripped before reaching the backend",
);
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
@ -2192,8 +2217,7 @@ mod tests {
let request = unwrap_spawn(rx.recv().await.unwrap());
assert_eq!(request.cwd.as_deref(), Some("/tmp"));
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
@ -2241,8 +2265,7 @@ mod tests {
assert_eq!(request.cwd.as_deref(), Some("/tmp/some-dir"));
assert_eq!(request.resume_from.as_deref(), Some("prev-id"));
request
.result_tx
.send(SubagentResult {
.respond_with(|request| SubagentResult {
success: true,
output: "resumed".into(),
subagent_id: request.id.clone(),
@ -2301,13 +2324,14 @@ mod tests {
);
assert!(request.runtime_overrides.reasoning_effort.is_none());
assert!(request.runtime_overrides.persona.is_none());
let id = request.id.clone();
request
.result_tx
.send(SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
child_session_id: request.id.clone(),
subagent_id: id.clone(),
child_session_id: id,
..Default::default()
})
.unwrap();
@ -2338,13 +2362,14 @@ mod tests {
"omitted model must stay None, got {:?}",
request.runtime_overrides.model
);
let id = request.id.clone();
request
.result_tx
.send(SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
child_session_id: request.id.clone(),
subagent_id: id.clone(),
child_session_id: id,
..Default::default()
})
.unwrap();
@ -2387,13 +2412,14 @@ mod tests {
"sentinel {sentinel:?} must normalize to None, got {:?}",
request.runtime_overrides.model
);
let id = request.id.clone();
request
.result_tx
.send(SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
child_session_id: request.id.clone(),
subagent_id: id.clone(),
child_session_id: id,
..Default::default()
})
.unwrap();
@ -2427,13 +2453,14 @@ mod tests {
Some("test-model"),
"leading/trailing whitespace should be trimmed"
);
let id = request.id.clone();
request
.result_tx
.send(SubagentResult {
success: true,
output: "ok".into(),
subagent_id: request.id.clone(),
child_session_id: request.id.clone(),
subagent_id: id.clone(),
child_session_id: id,
..Default::default()
})
.unwrap();
@ -2467,13 +2494,14 @@ mod tests {
);
assert!(request.runtime_overrides.reasoning_effort.is_none());
assert!(request.runtime_overrides.persona.is_none());
let id = request.id.clone();
request
.result_tx
.send(SubagentResult {
success: true,
output: "resumed".into(),
subagent_id: request.id.clone(),
child_session_id: request.id.clone(),
subagent_id: id.clone(),
child_session_id: id,
..Default::default()
})
.unwrap();
@ -2502,13 +2530,14 @@ mod tests {
let request = unwrap_spawn(rx.recv().await.unwrap());
assert_eq!(request.resume_from.as_deref(), Some("prev-id"));
assert!(request.runtime_overrides.model.is_none());
let id = request.id.clone();
request
.result_tx
.send(SubagentResult {
success: true,
output: "resumed".into(),
subagent_id: request.id.clone(),
child_session_id: request.id.clone(),
subagent_id: id.clone(),
child_session_id: id,
..Default::default()
})
.unwrap();

View file

@ -1,7 +1,8 @@
//! Channel types for subagent communication (TaskTool ↔ MvpAgent coordinator).
//! Data and channel types for subagent coordination.
//!
//! These types define the request/response protocol between the `TaskTool`
//! (in `xai-grok-tools`) and the subagent coordinator (in `xai-grok-shell`).
//! Request data is deliberately separate from command reply envelopes. The
//! shared coordinator actor owns every reply sender and every lifecycle
//! transition; child runners receive only plain request data.
//!
//! ## Resource types
//!
@ -23,6 +24,8 @@ use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use xai_tool_types::{SubagentCapabilityMode, SubagentIsolationMode, WaitMode};
use crate::register_resource;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SubagentOwner {
#[default]
@ -51,13 +54,10 @@ impl SubagentOwner {
}
}
use crate::register_resource;
// Request / Response
/// Request emitted by TaskTool, received by MvpAgent coordinator.
#[derive(Educe)]
#[educe(Debug)]
/// Plain spawn request emitted by `TaskTool`.
#[derive(Debug, Clone)]
pub struct SubagentRequest {
/// Subagent ID (UUID v7). Same as `TaskToolInput.task_id`; becomes the child session ID.
pub id: String,
@ -75,15 +75,17 @@ pub struct SubagentRequest {
/// freshly rendered.
pub resume_from: Option<String>,
/// Explicit working directory for the child session.
/// Validated at spawn time in `handle_subagent_request()`.
/// Validated at spawn time by the injected child runner.
pub cwd: Option<String>,
/// Runtime overrides for the child agent.
pub runtime_overrides: SubagentRuntimeOverrides,
/// Whether this subagent was launched with `run_in_background: true`.
///
/// Background subagents survive parent-turn cancellation — they are
/// excluded from `cancel_by_parent_prompt_id` so the user can poll
/// results later via `get_task_output`.
/// Controls immediate handle delivery and completion surfacing. A
/// background child still auto-surfaces its completion to the model
/// (buffered reminder / auto-wake) when `surface_completion` is set —
/// background does not mean fire-and-forget. Prompt cancellation still
/// cancels every child owned by that prompt.
pub run_in_background: bool,
/// When false, the subagent's completion is NOT buffered for the
/// between-turn "idle completion" reminder — used by harness-internal
@ -95,11 +97,39 @@ pub struct SubagentRequest {
pub fork_context: bool,
pub owner: SubagentOwner,
pub cancel_token: CancellationToken,
/// Oneshot channel for the coordinator to send back the result.
}
/// Spawn command envelope owned by the coordinator mailbox.
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentSpawnRequest {
pub request: Box<SubagentRequest>,
#[educe(Debug(ignore))]
pub result_tx: oneshot::Sender<SubagentResult>,
}
impl std::ops::Deref for SubagentSpawnRequest {
type Target = SubagentRequest;
fn deref(&self) -> &Self::Target {
&self.request
}
}
impl SubagentSpawnRequest {
/// Build and send a reply while the plain request remains borrowable.
///
/// Primarily useful for channel adapters and deterministic test harnesses;
/// production lifecycle replies are owned by `SubagentCoordinator`.
pub fn respond_with(
self,
build: impl FnOnce(&SubagentRequest) -> SubagentResult,
) -> Result<(), SubagentResult> {
let result = build(&self.request);
self.result_tx.send(result)
}
}
/// Per-spawn dynamic runtime overrides for a subagent.
///
/// Optional values inherit from the parent or role default. Explicit values take
@ -410,12 +440,14 @@ impl SubagentResult {
// Query protocol
/// Query sent by TaskOutputTool, received by MvpAgent coordinator.
/// Query sent by `TaskOutputTool` to the shared coordinator actor.
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentQueryRequest {
/// The subagent ID to look up.
pub subagent_id: String,
/// Restrict the lookup to children owned by this parent session.
pub parent_session_id: Option<String>,
/// If true, coordinator waits for completion (up to timeout) before responding.
pub block: bool,
/// Max wait time in ms when blocking. Default 30s.
@ -449,6 +481,27 @@ pub struct SubagentSnapshot {
pub persona: Option<String>,
}
/// Lifecycle metadata returned to shell presentation and extension callers.
#[derive(Debug, Clone)]
pub struct SubagentInspection {
pub snapshot: SubagentSnapshot,
pub parent_session_id: String,
pub child_session_id: String,
pub fork_parent_prompt_id: Option<String>,
pub resumed_from: Option<String>,
}
impl SubagentSnapshot {
/// Whether the child is still in flight (initializing or running) — the
/// shared liveness rule every driver's blocking query loops on.
pub fn is_running(&self) -> bool {
matches!(
self.status,
SubagentSnapshotStatus::Running { .. } | SubagentSnapshotStatus::Initializing
)
}
}
/// Status of a subagent snapshot.
#[derive(Debug, Clone)]
pub enum SubagentSnapshotStatus {
@ -506,11 +559,11 @@ pub enum SubagentCancelTarget {
WorkflowRunId(String),
}
/// Cancel request sent by KillTaskTool or session cancellation paths,
/// received by MvpAgent coordinator.
/// Cancel request sent by `KillTaskTool` or session cancellation paths.
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentCancelRequest {
pub parent_session_id: Option<String>,
pub target: SubagentCancelTarget,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<SubagentCancelOutcome>,
@ -524,10 +577,11 @@ pub enum SubagentCancelOutcome {
}
/// Summary of a completed subagent, used for between-turn delivery.
/// Session ownership lives on the coordinator's `BufferedCompletion` wrapper;
/// drains are scoped there, so delivered summaries carry no owner field.
#[derive(Debug, Clone)]
pub struct SubagentCompletionSummary {
pub subagent_id: String,
pub owner_session_id: String,
pub subagent_type: String,
pub description: String,
pub success: bool,
@ -560,7 +614,7 @@ pub struct SubagentMultiWaitRequest {
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentCompletionsRequest {
pub session_id: String,
pub parent_session_id: Option<String>,
pub suppress_ids: Vec<String>,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<Vec<SubagentCompletionSummary>>,
@ -580,6 +634,7 @@ pub struct SubagentOutstandingReply {
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentOutstandingRequest {
pub parent_session_id: String,
pub prompt_id: String,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<SubagentOutstandingReply>,
@ -588,6 +643,7 @@ pub struct SubagentOutstandingRequest {
/// Clear sticky incomplete after freeze/cancel has snapshotted the bill.
#[derive(Debug)]
pub struct SubagentClearUsageNotAppliedRequest {
pub parent_session_id: String,
pub prompt_id: String,
}
@ -595,11 +651,94 @@ pub struct SubagentClearUsageNotAppliedRequest {
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentMarkUsageNotAppliedRequest {
pub parent_session_id: String,
pub prompt_id: String,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<()>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SubagentRegistryCounts {
pub pending: usize,
pub active: usize,
pub completed: usize,
}
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentRegistryCountsRequest {
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<SubagentRegistryCounts>,
}
/// Request for full metadata plus a resolved progress snapshot.
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentInspectRequest {
pub subagent_id: String,
pub parent_session_id: Option<String>,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<Option<SubagentInspection>>,
}
/// Request for all running children owned by one parent session.
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentListRunningRequest {
pub parent_session_id: String,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<Vec<SubagentInspection>>,
}
/// Fork/resume provenance retained by the shared coordinator.
#[derive(Debug, Clone, Default)]
pub struct SubagentProvenance {
pub fork_parent_prompt_id: Option<String>,
pub resumed_from: Option<String>,
}
/// Reference to a child spawned during one parent prompt.
#[derive(Debug, Clone)]
pub struct SpawnedSubagentRef {
pub subagent_id: String,
pub child_session_id: String,
pub subagent_type: String,
pub description: String,
pub persona: Option<String>,
pub resumed_from: Option<String>,
}
/// Request for prompt-scoped spawned-child references.
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentSpawnedRefsRequest {
pub parent_session_id: String,
pub prompt_id: String,
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<Vec<SpawnedSubagentRef>>,
}
/// In-memory source data used by a runtime adapter to resume a child.
#[derive(Debug, Clone)]
pub struct SubagentResumeSource {
pub subagent_id: String,
pub child_session_id: String,
pub child_cwd: String,
pub worktree_path: Option<String>,
pub snapshot_ref: Option<String>,
pub subagent_type: String,
pub persona: Option<String>,
pub model_id: Option<String>,
}
/// Result of a resume-source lookup.
#[derive(Debug, Clone)]
pub enum SubagentResumeLookup {
Active,
Completed(SubagentResumeSource),
Missing,
}
// Validate-type protocol
#[derive(Debug, Clone)]
@ -700,18 +839,25 @@ pub struct SubagentDescribeRequest {
pub respond_to: oneshot::Sender<SubagentDescribeOutcome>,
}
/// Coordinator message enum. Intentionally NOT `#[non_exhaustive]` —
/// the cross-crate drain loop in `xai-grok-shell` relies on
/// compile-time exhaustiveness.
/// Coordinator message enum. Kept exhaustive so every actor command is handled.
pub enum SubagentEvent {
Spawn(Box<SubagentRequest>),
Spawn(SubagentSpawnRequest),
Query(SubagentQueryRequest),
Cancel(SubagentCancelRequest),
ListActive(SubagentListActiveRequest),
ListRunning(SubagentListRunningRequest),
Completions(SubagentCompletionsRequest),
/// Fire-and-forget: drop buffered completions owned by a removed session
/// so unloaded sessions cannot leak entries into the shared buffer.
DiscardSessionCompletions {
parent_session_id: String,
},
Outstanding(SubagentOutstandingRequest),
ClearUsageNotApplied(SubagentClearUsageNotAppliedRequest),
MarkUsageNotApplied(SubagentMarkUsageNotAppliedRequest),
RegistryCounts(SubagentRegistryCountsRequest),
Inspect(SubagentInspectRequest),
SpawnedRefs(SubagentSpawnedRefsRequest),
ValidateType(SubagentValidateTypeRequest),
DescribeType(SubagentDescribeRequest),
LoopUnitActive(SubagentLoopUnitActiveRequest),
@ -780,10 +926,8 @@ pub fn drain_owned(
/// Lightweight summary of a running subagent.
///
/// This is the single shared definition of this type. The coordinator in
/// xai-grok-shell produces it, the channel protocol carries it, and the
/// compaction pipeline in xai-chat-state (via `RunningSubagentSummary`)
/// consumes it. Do not duplicate this type in other crates.
/// The shared coordinator produces this through the channel protocol, and the
/// compaction pipeline consumes it through `RunningSubagentSummary`.
#[derive(Debug, Clone)]
pub struct ActiveSubagentSummary {
/// The subagent's unique ID (same ID used by `get_task_output` / `kill_task`).
@ -799,8 +943,7 @@ pub struct ActiveSubagentSummary {
/// Request to list currently-running subagents for a specific parent session.
///
/// Sent by the compaction pipeline in `SessionActor::run_compact_inner()`.
/// Handled by `MvpAgent::start_subagent_coordinator()` which borrows the
/// coordinator and calls `active_summaries_for()`.
/// Handled by the shared coordinator actor.
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentListActiveRequest {
@ -853,6 +996,39 @@ pub struct SessionIdResource(pub String);
register_resource!("grok_build", "SessionIdResource", SessionIdResource);
/// Host-owned RAII token for an interruptible foreground wait.
pub trait ForegroundWaitGuard: Send {}
impl<T: Send> ForegroundWaitGuard for T {}
type ForegroundWaitFactory = dyn Fn() -> Box<dyn ForegroundWaitGuard> + Send + Sync;
/// Factory injected by hosts that expose a send-now wait window.
#[derive(Clone)]
pub struct SubagentForegroundWait(Arc<ForegroundWaitFactory>);
impl SubagentForegroundWait {
pub fn new(factory: impl Fn() -> Box<dyn ForegroundWaitGuard> + Send + Sync + 'static) -> Self {
Self(Arc::new(factory))
}
pub fn enter(&self) -> Box<dyn ForegroundWaitGuard> {
(self.0)()
}
}
impl std::fmt::Debug for SubagentForegroundWait {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubagentForegroundWait").finish()
}
}
register_resource!(
"grok_build",
"SubagentForegroundWait",
SubagentForegroundWait
);
/// Carries the current parent prompt/turn ID for TaskTool subagent scoping.
///
/// Set by xai-grok-shell immediately before a prompt turn begins executing so
@ -1282,19 +1458,18 @@ mod tests {
let (respond_to, mut response_rx) = oneshot::channel();
tx.send(super::SubagentCompletionsRequest {
session_id: "session-1".into(),
parent_session_id: Some("parent".into()),
suppress_ids: vec!["id-1".into(), "id-2".into()],
respond_to,
})
.unwrap();
let req = rx.try_recv().unwrap();
assert_eq!(req.session_id, "session-1");
assert_eq!(req.parent_session_id.as_deref(), Some("parent"));
assert_eq!(req.suppress_ids, vec!["id-1", "id-2"]);
let summaries = vec![super::SubagentCompletionSummary {
subagent_id: "sub-1".into(),
owner_session_id: "session-1".into(),
subagent_type: "general-purpose".into(),
description: "test task".into(),
success: true,
@ -1373,7 +1548,7 @@ mod tests {
.0
.send(super::SubagentEvent::Completions(
super::SubagentCompletionsRequest {
session_id: String::new(),
parent_session_id: None,
suppress_ids: vec![],
respond_to,
},
@ -1405,7 +1580,7 @@ mod tests {
.0
.send(super::SubagentEvent::Completions(
super::SubagentCompletionsRequest {
session_id: String::new(),
parent_session_id: None,
suppress_ids: vec![],
respond_to,
},

View file

@ -916,6 +916,7 @@ pub(crate) mod test_helpers {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
}
}

View file

@ -147,7 +147,7 @@ Content output format:
Usage:
- ${{ params.search.pattern }} is a regex: `log.*Error`, `function\s+\w+`, `TODO`
- Output modes: "content" (default, with anchors), "files_with_matches", "count"
- Default output is anchored content matches (no output-mode selector)
- Use -A, -B, -C for context lines around matches
- Only use '${{ params.search.type }}' or '${{ params.search.glob }}' when certain of the file type
- Results are capped; truncated results show "at least" counts"#;

View file

@ -394,7 +394,9 @@ impl xai_tool_runtime::Tool for BashTool {
auto_background_on_timeout: false, // OpenCode doesn't support auto-backgrounding
foreground_block_budget: None,
kind: crate::computer::types::TaskKind::Bash,
owner_session_id: None, // OpenCode doesn't use shared terminal backends
// OpenCode doesn't use shared terminal backends.
owner_session_id: None,
description: None,
};
let result = match backend.run(request).await {

View file

@ -107,6 +107,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
}
}

View file

@ -1,11 +1,13 @@
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use super::types::{
BashExecutionBackgrounded, BashExecutionComplete, BashExecutionFailed, BashExecutionTimeout,
BashOutputChunk, FileWritten, LspServerCrashed, LspServerFailed, LspServerReady,
LspServerRetrying, LspServerStarting, MonitorEvent, PlanModeEntered, PlanModeExited,
ScheduledTaskCreated, ScheduledTaskFired, ScheduledTaskRemoved, ToolNotification,
UserQuestionAsked,
ScheduledTaskCreated, ScheduledTaskFired, ScheduledTaskRemoved, SubagentCompleted,
ToolNotification, UserQuestionAsked,
};
use crate::types::TaskSnapshot;
@ -90,9 +92,87 @@ impl NotificationAcknowledgementBatch {
#[derive(Clone)]
enum ToolNotificationTarget {
Plain(tokio::sync::mpsc::UnboundedSender<ToolNotification>),
Bounded(tokio::sync::mpsc::Sender<ToolNotification>),
Capped(Arc<CappedNotificationQueue>),
Acknowledged(tokio::sync::mpsc::UnboundedSender<AcknowledgedToolNotification>),
}
struct CappedNotificationQueue {
queue: parking_lot::Mutex<VecDeque<ToolNotification>>,
capacity: usize,
closed: AtomicBool,
ready: tokio::sync::Notify,
}
impl CappedNotificationQueue {
fn push(&self, notification: ToolNotification) {
if self.closed.load(Ordering::Relaxed) {
return;
}
let mut queue = self.queue.lock();
if queue.len() >= self.capacity {
if !is_critical_notification(&notification) {
tracing::warn!("tool notification queue full; dropping newest lossy event");
return;
}
let evict = queue
.iter()
.position(|queued| !is_critical_notification(queued))
.unwrap_or(0);
queue.remove(evict);
tracing::warn!("tool notification queue full; evicting older event for terminal event");
}
queue.push_back(notification);
drop(queue);
self.ready.notify_one();
}
}
fn is_critical_notification(notification: &ToolNotification) -> bool {
matches!(
notification,
ToolNotification::BashExecutionComplete(_)
| ToolNotification::BashExecutionTimeout(_)
| ToolNotification::BashExecutionFailed(_)
| ToolNotification::TaskCompleted(_)
| ToolNotification::SubagentCompleted(_)
| ToolNotification::PlanModeEntered(_)
| ToolNotification::PlanModeExited(_)
| ToolNotification::UserQuestionAsked(_)
| ToolNotification::LspServerCrashed(_)
| ToolNotification::LspServerFailed(_)
| ToolNotification::ScheduledTaskFired(_)
| ToolNotification::ScheduledTaskRemoved(_)
)
}
/// Receiver for a capped queue that preserves terminal notifications.
pub struct CappedToolNotificationReceiver {
queue: Arc<CappedNotificationQueue>,
}
impl CappedToolNotificationReceiver {
pub async fn recv(&mut self) -> Option<ToolNotification> {
loop {
let ready = self.queue.ready.notified();
if let Some(notification) = self.queue.queue.lock().pop_front() {
return Some(notification);
}
if self.queue.closed.load(Ordering::Relaxed) {
return None;
}
ready.await;
}
}
}
impl Drop for CappedToolNotificationReceiver {
fn drop(&mut self) {
self.queue.closed.store(true, Ordering::Relaxed);
self.queue.ready.notify_waiters();
}
}
/// Cloneable notification fan-out with per-target FIFO ordering.
#[derive(Clone)]
pub struct ToolNotificationHandle {
@ -127,6 +207,35 @@ impl ToolNotificationHandle {
(Self::new(sender), receiver)
}
/// Create a capped target that drops the newest event when full.
pub fn bounded_channel(
capacity: usize,
) -> (Self, tokio::sync::mpsc::Receiver<ToolNotification>) {
let (sender, receiver) = tokio::sync::mpsc::channel(capacity);
(
Self {
targets: Arc::from([ToolNotificationTarget::Bounded(sender)]),
},
receiver,
)
}
/// Create a capped queue that evicts lossy events before terminal events.
pub fn capped_channel(capacity: usize) -> (Self, CappedToolNotificationReceiver) {
let queue = Arc::new(CappedNotificationQueue {
queue: parking_lot::Mutex::new(VecDeque::new()),
capacity: capacity.max(1),
closed: AtomicBool::new(false),
ready: tokio::sync::Notify::new(),
});
(
Self {
targets: Arc::from([ToolNotificationTarget::Capped(Arc::clone(&queue))]),
},
CappedToolNotificationReceiver { queue },
)
}
pub fn acknowledged_channel() -> (
Self,
tokio::sync::mpsc::UnboundedReceiver<AcknowledgedToolNotification>,
@ -185,6 +294,12 @@ impl ToolNotificationHandle {
ToolNotificationTarget::Plain(target) => {
let _ = target.send(notification);
}
ToolNotificationTarget::Bounded(target) => {
if target.try_send(notification).is_err() {
tracing::warn!("tool notification queue full; dropping newest event");
}
}
ToolNotificationTarget::Capped(target) => target.push(notification),
ToolNotificationTarget::Acknowledged(target) => {
let _ = target.send(AcknowledgedToolNotification {
notification,
@ -210,6 +325,12 @@ impl ToolNotificationHandle {
ToolNotificationTarget::Plain(target) => {
let _ = target.send(notification.clone());
}
ToolNotificationTarget::Bounded(target) => {
if target.try_send(notification.clone()).is_err() {
tracing::warn!("tool notification queue full; dropping newest event");
}
}
ToolNotificationTarget::Capped(target) => target.push(notification.clone()),
ToolNotificationTarget::Acknowledged(target) => {
let (acknowledgement, receipt) = tokio::sync::oneshot::channel();
if target
@ -237,6 +358,7 @@ impl ToolNotificationHandle {
send_failed, BashExecutionFailed, BashExecutionFailed;
send_file_written, FileWritten, FileWritten;
send_task_complete, TaskSnapshot, TaskCompleted;
send_subagent_completed, SubagentCompleted, SubagentCompleted;
send_plan_mode_entered, PlanModeEntered, PlanModeEntered;
send_plan_mode_exited, PlanModeExited, PlanModeExited;
send_user_question_asked, UserQuestionAsked, UserQuestionAsked;

View file

@ -106,3 +106,22 @@ async fn batch_distinguishes_dropped_and_rejected_acknowledgements() {
})
);
}
#[tokio::test]
async fn bounded_channel_drops_newest_when_full() {
let (handle, mut receiver) = ToolNotificationHandle::bounded_channel(1);
handle.send_scheduled_task_created(created("kept"));
handle.send_scheduled_task_created(created("dropped"));
assert_eq!(task_id(&receiver.recv().await.unwrap()), "kept");
assert!(receiver.try_recv().is_err());
}
#[tokio::test]
async fn capped_channel_evicts_lossy_event_for_terminal_event() {
let (handle, mut receiver) = ToolNotificationHandle::capped_channel(1);
handle.send_scheduled_task_created(created("lossy"));
handle.send(ToolNotification::ScheduledTaskRemoved(removed("terminal")));
assert_eq!(task_id(&receiver.recv().await.unwrap()), "terminal");
}

View file

@ -2,6 +2,7 @@ pub mod handle;
pub mod types;
pub use handle::AcknowledgedToolNotification;
pub use handle::CappedToolNotificationReceiver;
pub use handle::DurableNotificationTargets;
pub use handle::NotificationAcknowledgementBatch;
pub use handle::NotificationAcknowledgementError;

View file

@ -368,6 +368,21 @@ pub struct MonitorEvent {
pub owner_session_id: Option<String>,
}
/// A background subagent reached a terminal state while the parent held a handle.
#[derive(Debug, Clone, PartialEq, Eq, schemars::JsonSchema)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SubagentCompleted {
pub subagent_id: String,
pub subagent_type: String,
pub description: String,
pub status: String,
#[cfg_attr(feature = "serde", serde(default))]
pub error: Option<String>,
pub duration_ms: u64,
#[cfg_attr(feature = "serde", serde(default))]
pub owner_session_id: Option<String>,
}
/// A notification emitted by a tool during or after execution.
/// These are sent to external consumers (TUI, logging, etc.) to provide
/// real-time visibility into tool execution.
@ -398,6 +413,9 @@ pub enum ToolNotification {
/// about the task being finished status
TaskCompleted(TaskSnapshot),
/// A background subagent reached a terminal state.
SubagentCompleted(SubagentCompleted),
/// The agent requested to enter plan mode.
/// Consumers (gateway, TUI) use this to transition the client into
/// plan-mode UI state (e.g., enforce read-only, inject plan-mode
@ -480,6 +498,7 @@ notification_variants! {
BashExecutionFailed => BashExecutionFailed,
FileWritten => FileWritten,
TaskCompleted => TaskSnapshot,
SubagentCompleted => SubagentCompleted,
PlanModeEntered => PlanModeEntered,
PlanModeExited => PlanModeExited,
UserQuestionAsked => UserQuestionAsked,

View file

@ -210,6 +210,15 @@ pub struct ToolServerConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub behavior_preset: Option<String>,
}
#[derive(Clone)]
pub struct SubagentSessionResources {
pub backend: crate::implementations::grok_build::task::backend::SubagentBackendResource,
/// Same channel as [`Self::backend`]; required so `TaskCompletionReminder`
/// can drain completions onto the next tool result (shell parity).
pub event_sender: crate::implementations::grok_build::task::types::SubagentEventSender,
pub depth: crate::implementations::grok_build::task::types::SubagentDepthCounter,
pub session_id: crate::implementations::grok_build::task::types::SessionIdResource,
}
/// Everything a session provides at finalization time.
///
/// This is the **public API boundary** — callers pass concrete, strongly-typed
@ -231,6 +240,8 @@ pub struct SessionContext {
/// Session ID that owns processes spawned by this session's tools.
/// Used to scope kill operations on a shared terminal backend.
pub owner_session_id: Option<String>,
/// Complete subagent capability for this session.
pub subagent: Option<SubagentSessionResources>,
/// Parent's scheduler handle. When `Some`, the session reuses the parent's
/// scheduler actor instead of spawning its own, so scheduled tasks survive
/// subagent exit.
@ -975,6 +986,12 @@ impl ToolRegistryBuilder {
if let Some(owner_session_id) = ctx.owner_session_id {
resources.insert(crate::types::resources::OwnerSessionId(owner_session_id));
}
if let Some(subagent) = ctx.subagent {
resources.insert(subagent.backend);
resources.insert(subagent.event_sender);
resources.insert(subagent.depth);
resources.insert(subagent.session_id);
}
let scheduler_notification_handle = ctx.notification_handle.clone();
resources.insert(crate::types::resources::NotificationHandle(
ctx.notification_handle,
@ -1303,6 +1320,23 @@ impl FinalizedToolset {
.map(|t| (t.client_name.clone(), t.metadata.kind().as_key().to_owned()))
.collect()
}
/// Map of client-facing tool name → typed [`ToolKind`].
///
/// Unlike the finalize-request `ToolConfig`s (whose `kind` is `None` when
/// built from raw IDs over gRPC), the finalized tools always know their
/// real kind from the registry metadata — use this for kind-derived
/// metadata in server responses (e.g. capability-mode classification).
pub fn tool_kind_map(&self) -> HashMap<String, ToolKind> {
self.tools
.read()
.iter()
.map(|t| (t.client_name.clone(), t.metadata.kind()))
.collect()
}
/// Finalized canonical-to-client parameter names by tool kind.
pub fn template_param_names(&self) -> HashMap<ToolKind, HashMap<String, String>> {
self.renderer.param_names()
}
pub async fn update_resource<T: Send + Sync + 'static>(&self, resource: T) {
self.resources.lock().await.insert(resource);
}
@ -1420,6 +1454,9 @@ impl FinalizedToolset {
let mut ctx = xai_tool_runtime::ToolCallContext::new(parent_ctx.call_id.clone());
ctx.extensions.insert(self.resources.clone());
ctx.extensions.insert_arc(Arc::clone(&self.renderer));
if let Some(cancellation) = parent_ctx.get::<xai_tool_runtime::Cancellation>() {
ctx.extensions.insert((*cancellation).clone());
}
ctx.extensions.insert(
crate::types::resources::InvokingToolParamNames::from_reverse_params(&reverse_params),
);
@ -1451,9 +1488,27 @@ impl FinalizedToolset {
tool_args: serde_json::Value,
tool_call_id: &str,
cwd_override: Option<std::path::PathBuf>,
) -> Result<ToolRunResult, xai_tool_runtime::ToolError> {
self.call_with_cancellation(tool_name, tool_args, tool_call_id, cwd_override, None)
.await
}
/// Dispatch with cooperative cancellation exposed to the tool.
pub async fn call_with_cancellation(
self: &Arc<Self>,
tool_name: &str,
tool_args: serde_json::Value,
tool_call_id: &str,
cwd_override: Option<std::path::PathBuf>,
cancellation: Option<tokio_util::sync::CancellationToken>,
) -> Result<ToolRunResult, xai_tool_runtime::ToolError> {
use futures::StreamExt;
let mut stream = self.call_streaming(tool_name, tool_args, tool_call_id, cwd_override);
let mut stream = self.call_streaming_with_cancellation(
tool_name,
tool_args,
tool_call_id,
cwd_override,
cancellation,
);
while let Some(item) = stream.next().await {
match item {
xai_tool_runtime::ToolStreamItem::Progress(_) => continue,
@ -1482,6 +1537,23 @@ impl FinalizedToolset {
tool_args: serde_json::Value,
tool_call_id: &str,
cwd_override: Option<std::path::PathBuf>,
) -> xai_tool_runtime::ToolStream<ToolRunResult> {
self.call_streaming_with_cancellation(
tool_name,
tool_args,
tool_call_id,
cwd_override,
None,
)
}
/// Streaming dispatch with cooperative cancellation exposed to the tool.
pub fn call_streaming_with_cancellation(
self: &Arc<Self>,
tool_name: &str,
tool_args: serde_json::Value,
tool_call_id: &str,
cwd_override: Option<std::path::PathBuf>,
cancellation: Option<tokio_util::sync::CancellationToken>,
) -> xai_tool_runtime::ToolStream<ToolRunResult> {
use futures::StreamExt;
let this = Arc::clone(self);
@ -1493,6 +1565,7 @@ impl FinalizedToolset {
tool_args,
&tool_call_id,
cwd_override,
cancellation,
) {
Ok(parts) => parts,
Err(e) => {
@ -1542,6 +1615,7 @@ impl FinalizedToolset {
tool_args: serde_json::Value,
tool_call_id: &str,
cwd_override: Option<std::path::PathBuf>,
cancellation: Option<tokio_util::sync::CancellationToken>,
) -> Result<DispatchParts, xai_tool_runtime::ToolError> {
let (registry_id, output_converter, reverse_params) = {
let tools = self.tools.read();
@ -1581,6 +1655,10 @@ impl FinalizedToolset {
if let Some(cwd) = cwd_override {
ctx.extensions.insert(xai_tool_runtime::Cwd(cwd));
}
if let Some(cancellation) = cancellation {
ctx.extensions
.insert(xai_tool_runtime::Cancellation(cancellation));
}
if let Some(ref version) = contract_version {
ctx.extensions
.insert(xai_tool_runtime::BehaviorVersion(version.clone()));
@ -1807,6 +1885,10 @@ pub fn generate_schema<T: schemars::JsonSchema>() -> serde_json::Value {
let generator = settings.into_generator();
let schema = generator.into_root_schema_for::<T>();
let mut value = serde_json::to_value(&schema).unwrap_or_default();
if let Some(obj) = value.as_object_mut() {
obj.remove("title");
obj.remove("description");
}
if let Some(obj) = value.as_object_mut()
&& obj.get("type").and_then(|v| v.as_str()) == Some("object")
{
@ -2036,6 +2118,7 @@ mod tests {
session_env: Arc::new(HashMap::new()),
notification_handle: crate::notification::ToolNotificationHandle::noop(),
owner_session_id: None,
subagent: None,
parent_scheduler_handle: None,
skills: vec![],
state_path: tmp.path().join("state.json"),
@ -4415,6 +4498,29 @@ mod tests {
assert_eq!(skills.0.len(), 2, "should have exactly 2 skills");
}
}
/// generate_schema strips the boilerplate root `title` (struct name) and
/// root `description` (struct doc "Input for the <canonical> tool") so the
/// canonical name can't leak via parameters.description after randomization
/// renames the tool. $schema and per-property descriptions are retained.
#[test]
fn generate_schema_strips_root_title_and_description() {
let schema = generate_schema::<crate::implementations::grok_build::bash::BashToolInput>();
assert!(
schema.get("title").is_none(),
"root title (struct name) must be stripped: {schema}"
);
assert!(
schema.get("description").is_none(),
"root description (leaks canonical tool name) must be stripped: {schema}"
);
assert!(schema.get("$schema").is_some(), "$schema must be retained");
assert!(
schema["properties"]
.as_object()
.is_some_and(|p| !p.is_empty()),
"per-property schema must be retained: {schema}"
);
}
fn toolset_with_viewer_ctx(
viewer_ctx: Option<xai_tool_runtime::WorkspaceViewerContext>,
) -> (Arc<FinalizedToolset>, TempDir) {
@ -4455,6 +4561,7 @@ mod tests {
serde_json::json!({"target_file": "noop"}),
"test-call",
None,
None,
)
.expect("prepare_dispatch succeeds");
let wvc = parts
@ -4473,6 +4580,7 @@ mod tests {
serde_json::json!({"target_file": "noop"}),
"test-call",
None,
None,
)
.expect("prepare_dispatch succeeds");
assert!(

View file

@ -643,14 +643,13 @@ impl Reminder for TaskCompletionReminder {
.chain(&reserved_ids)
.cloned()
.collect::<Vec<_>>();
let (terminal, event_sender, session_id) = {
let (terminal, event_sender, parent_session_id) = {
let res = resources.lock().await;
(
res.get::<Terminal>().map(|t| t.0.clone()),
res.get::<SubagentEventSender>().cloned(),
res.get::<crate::implementations::grok_build::task::types::SessionIdResource>()
.map(|s| s.0.clone())
.unwrap_or_default(),
res.get::<crate::types::resources::OwnerSessionId>()
.map(|owner| owner.0.clone()),
)
};
let mut reminders = Vec::new();
@ -733,7 +732,7 @@ impl Reminder for TaskCompletionReminder {
if sender
.0
.send(SubagentEvent::Completions(SubagentCompletionsRequest {
session_id,
parent_session_id,
suppress_ids,
respond_to: tx,
}))
@ -802,6 +801,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("abc-123"));
@ -828,6 +828,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let msg = format_monitor_completion(&task, Some("get_command_or_subagent_output"));
assert!(
@ -860,6 +861,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let msg = format_monitor_completion(&task, None);
assert!(
@ -887,6 +889,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("cargo test"));
@ -911,6 +914,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("exit code: unknown"));
@ -938,6 +942,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(
@ -976,6 +981,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(
@ -1013,6 +1019,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("exit code: 0"));
@ -1173,6 +1180,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
}
}
fn make_running(id: &str) -> TaskSnapshot {
@ -1193,6 +1201,7 @@ mod tests {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
}
}
fn make_bg_started(id: &str) -> crate::types::output::BackgroundTaskStarted {
@ -1438,7 +1447,6 @@ mod tests {
fn make_subagent_completion(id: &str, success: bool) -> SubagentCompletionSummary {
SubagentCompletionSummary {
subagent_id: id.into(),
owner_session_id: String::new(),
subagent_type: "general-purpose".into(),
description: "test task".into(),
success,

View file

@ -175,6 +175,19 @@ impl TemplateRenderer {
render_with_env(template, &self.ctx)
}
/// Return the finalized canonical-to-client parameter names by tool kind.
pub fn param_names(&self) -> HashMap<ToolKind, HashMap<String, String>> {
self.ctx
.params
.iter()
.filter_map(|(kind, names)| {
serde_json::from_value(serde_json::Value::String(kind.clone()))
.ok()
.map(|kind| (kind, names.clone()))
})
.collect()
}
/// Render `${{ ... }}` placeholders in every `description` string within a
/// JSON Schema, in place — recursing into nested objects, array `items`, and
/// `$defs`. Property keys are remapped separately; this resolves

View file

@ -13,6 +13,7 @@ pub mod path_suggestions;
pub(crate) mod query_tools;
pub mod remap;
pub mod serde_base64;
pub mod shell_env_policy;
pub mod spawn;
pub mod truncate;
pub mod unicode_confusables;
@ -26,6 +27,10 @@ pub use fs::{UnicodePathMatch, canonicalize_with_timeout, try_resolve_unicode_fi
pub use grok_home::{grok_application, grok_home};
pub use path_suggestions::format_not_found_error;
pub use remap::{remap_json_keys, remap_schema_properties, reverse_map};
pub use shell_env_policy::{
EnvironmentVariablePattern, ShellEnvironmentPolicy, ShellEnvironmentPolicyInherit,
apply_shell_environment_policy,
};
pub use spawn::{
ProcessGroup, ProcessScope, detach_command, global_process_scope, new_process_group,
};

View file

@ -0,0 +1,237 @@
//! Controls which environment variables agent subprocesses (bash tool,
//! terminals) inherit. Default is a no-op (inherit everything); enforced at the
//! shell spawn sites on macOS, Linux, and Windows.
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::LazyLock;
use wildmatch::WildMatchPattern;
/// Case-insensitive environment-variable-name glob (`*`, `?`).
pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
fn deserialize_patterns<'de, D>(
deserializer: D,
) -> Result<Vec<EnvironmentVariablePattern>, D::Error>
where
D: serde::Deserializer<'de>,
{
let globs = Vec::<String>::deserialize(deserializer)?;
Ok(globs
.iter()
.map(|s| EnvironmentVariablePattern::new_case_insensitive(s))
.collect())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
/// Core platform variables only (PATH, HOME, SHELL, ...).
Core,
#[default]
All,
None,
}
/// How to build the environment for agent subprocesses. Applied in order: start
/// from `inherit`; if `ignore_default_excludes` is false, drop the secret
/// patterns `*KEY*`/`*SECRET*`/`*TOKEN*`; drop `exclude`; insert `set`; if
/// `include_only` is non-empty, keep only those. Patterns are case-insensitive
/// globs (`*`, `?`).
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
#[serde(default)]
pub struct ShellEnvironmentPolicy {
pub inherit: ShellEnvironmentPolicyInherit,
/// Skip the built-in secret excludes (default `true`).
pub ignore_default_excludes: bool,
#[serde(deserialize_with = "deserialize_patterns")]
pub exclude: Vec<EnvironmentVariablePattern>,
/// Values inserted into the base environment before `include_only` filtering
/// (an unmatched name is then dropped). These seed the base; request env
/// layered at spawn can still override them.
pub set: HashMap<String, String>,
#[serde(deserialize_with = "deserialize_patterns")]
pub include_only: Vec<EnvironmentVariablePattern>,
}
impl Default for ShellEnvironmentPolicy {
fn default() -> Self {
Self {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: true,
exclude: Vec::new(),
set: HashMap::new(),
include_only: Vec::new(),
}
}
}
impl ShellEnvironmentPolicy {
/// True when the policy leaves the inherited environment untouched.
pub fn is_noop(&self) -> bool {
self.inherit == ShellEnvironmentPolicyInherit::All
&& self.ignore_default_excludes
&& self.exclude.is_empty()
&& self.set.is_empty()
&& self.include_only.is_empty()
}
/// True if `name` matches a built-in secret exclude and those are enabled.
fn matches_default_exclude(&self, name: &str) -> bool {
!self.ignore_default_excludes && DEFAULT_SECRET_EXCLUDES.iter().any(|p| p.matches(name))
}
fn matches_exclude(&self, name: &str) -> bool {
self.exclude.iter().any(|p| p.matches(name))
}
/// True if `include_only` is empty (all admitted) or `name` matches it.
fn matches_include_only(&self, name: &str) -> bool {
self.include_only.is_empty() || self.include_only.iter().any(|p| p.matches(name))
}
/// Whether `name` survives the name filters (default excludes, `exclude`,
/// `include_only`), ignoring `inherit`/`set`. Used to filter variables layered
/// in after the policy base, e.g. login-shell capture. Shares its matchers
/// with [`create_env_from_vars`] so the two cannot drift.
pub fn allows(&self, name: &str) -> bool {
!self.matches_default_exclude(name)
&& !self.matches_exclude(name)
&& self.matches_include_only(name)
}
/// Like [`allows`](Self::allows) but also honors `inherit`: `none` admits
/// nothing, `core` admits only core names, `all` defers to `allows`.
pub fn allows_with_inherit(&self, name: &str) -> bool {
match self.inherit {
ShellEnvironmentPolicyInherit::None => return false,
ShellEnvironmentPolicyInherit::Core => {
if !CORE_ENV_VARS
.iter()
.any(|core| core.eq_ignore_ascii_case(name))
{
return false;
}
}
ShellEnvironmentPolicyInherit::All => {}
}
self.allows(name)
}
}
/// Built-in secret excludes applied when `ignore_default_excludes` is false.
/// Shared by the base-env build and the login-capture filter so they can't drift.
static DEFAULT_SECRET_EXCLUDES: LazyLock<[EnvironmentVariablePattern; 3]> = LazyLock::new(|| {
[
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
]
});
/// "Core" variables retained under [`ShellEnvironmentPolicyInherit::Core`].
#[cfg(not(target_os = "windows"))]
const CORE_ENV_VARS: &[&str] = &[
"PATH", "SHELL", "TMPDIR", "TEMP", "TMP", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME",
"USER",
];
#[cfg(target_os = "windows")]
const CORE_ENV_VARS: &[&str] = &[
"PATH",
"PATHEXT",
"SHELL",
"COMSPEC",
"SYSTEMROOT",
"SYSTEMDRIVE",
"USERNAME",
"USERDOMAIN",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"PROGRAMDATA",
"LOCALAPPDATA",
"APPDATA",
"TEMP",
"TMP",
"TMPDIR",
"POWERSHELL",
"PWSH",
];
/// Build the child environment from `policy` and the process env. Uses `vars_os`
/// and skips non-UTF-8 entries so a hostile variable cannot panic at spawn time.
pub(crate) fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap<String, String> {
let vars = std::env::vars_os()
.filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)));
create_env_from_vars(vars, policy)
}
pub(crate) fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
let mut env: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => vars
.into_iter()
.filter(|(k, _)| {
CORE_ENV_VARS
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(k))
})
.collect(),
};
// Order matters: default excludes, then `exclude`, then `set`, then
// `include_only`. `set` lands before `include_only` so an unmatched set name
// is still dropped. The matchers are shared with `allows`.
env.retain(|k, _| !policy.matches_default_exclude(k));
env.retain(|k, _| !policy.matches_exclude(k));
for (k, v) in &policy.set {
env.insert(k.clone(), v.clone());
}
env.retain(|k, _| policy.matches_include_only(k));
// Windows resolves executables via PATHEXT; keep it present even under a
// restrictive policy so commands stay runnable.
if cfg!(target_os = "windows") && !env.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
env
}
/// Clear the command's inherited env and install the policy-derived base env.
/// `active` must already be noop-filtered; `None` leaves the command untouched.
/// The one base-env code path, shared by the public entry point and the spawn
/// sites.
pub(crate) fn install_policy_base_env(
cmd: &mut tokio::process::Command,
active: Option<&ShellEnvironmentPolicy>,
) {
if let Some(policy) = active {
cmd.env_clear();
cmd.envs(create_env(policy));
}
}
/// Install the policy-derived base env on `cmd` (clearing inherited env first);
/// a `None` or no-op policy leaves it untouched. Call before any other
/// `.env`/`.envs`.
pub fn apply_shell_environment_policy(
cmd: &mut tokio::process::Command,
policy: Option<&ShellEnvironmentPolicy>,
) {
install_policy_base_env(cmd, policy.filter(|p| !p.is_noop()));
}
#[cfg(test)]
#[path = "shell_env_policy_tests.rs"]
mod tests;

View file

@ -0,0 +1,166 @@
use super::{
EnvironmentVariablePattern, ShellEnvironmentPolicy, ShellEnvironmentPolicyInherit,
apply_shell_environment_policy, create_env_from_vars,
};
use std::collections::HashMap;
fn vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn patterns(globs: &[&str]) -> Vec<EnvironmentVariablePattern> {
globs
.iter()
.map(|g| EnvironmentVariablePattern::new_case_insensitive(g))
.collect()
}
#[test]
fn apply_policy_reshapes_command_env() {
let mut set = HashMap::new();
set.insert("MY_FLAG".to_string(), "1".to_string());
let policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
set,
..Default::default()
};
let mut cmd = tokio::process::Command::new("true");
apply_shell_environment_policy(&mut cmd, Some(&policy));
let envs: HashMap<String, String> = cmd
.as_std()
.get_envs()
.filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
.collect();
assert_eq!(envs.get("MY_FLAG").map(String::as_str), Some("1"));
// inherit=None cleared the env, so no inherited PATH leaks through.
assert!(!envs.contains_key("PATH"));
}
#[test]
fn apply_noop_or_absent_policy_leaves_command_untouched() {
let mut cmd = tokio::process::Command::new("true");
apply_shell_environment_policy(&mut cmd, None);
apply_shell_environment_policy(&mut cmd, Some(&ShellEnvironmentPolicy::default()));
// No env_clear and no sets: the command carries no explicit env entries.
assert_eq!(cmd.as_std().get_envs().count(), 0);
}
#[test]
fn default_excludes_drop_secrets_when_enabled() {
let policy = ShellEnvironmentPolicy {
ignore_default_excludes: false,
..Default::default()
};
assert!(!policy.is_noop());
let env = create_env_from_vars(
vars(&[
("PATH", "/bin"),
("MY_API_KEY", "x"),
("MY_SECRET", "y"),
("GH_TOKEN", "z"),
]),
&policy,
);
assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
assert!(!env.contains_key("MY_API_KEY"));
assert!(!env.contains_key("MY_SECRET"));
assert!(!env.contains_key("GH_TOKEN"));
}
#[test]
fn inherit_none_starts_empty_then_set_applies() {
let mut set = HashMap::new();
set.insert("PATH".to_string(), "/usr/bin".to_string());
set.insert("MY_FLAG".to_string(), "1".to_string());
let policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
set,
..Default::default()
};
let env = create_env_from_vars(vars(&[("PATH", "/bin"), ("HOME", "/root")]), &policy);
assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin"));
assert_eq!(env.get("MY_FLAG").map(String::as_str), Some("1"));
assert!(!env.contains_key("HOME"));
}
#[test]
fn inherit_core_keeps_only_core_vars() {
let policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::Core,
..Default::default()
};
let env = create_env_from_vars(vars(&[("PATH", "/bin"), ("RANDOM_VAR", "v")]), &policy);
assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
assert!(!env.contains_key("RANDOM_VAR"));
}
#[test]
fn exclude_and_include_only_filter() {
let policy = ShellEnvironmentPolicy {
exclude: patterns(&["AWS_*"]),
include_only: patterns(&["PATH", "HOME"]),
..Default::default()
};
let env = create_env_from_vars(
vars(&[
("PATH", "/bin"),
("HOME", "/root"),
("AWS_SECRET", "s"),
("OTHER", "o"),
]),
&policy,
);
assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
assert_eq!(env.get("HOME").map(String::as_str), Some("/root"));
assert!(!env.contains_key("AWS_SECRET"));
assert!(!env.contains_key("OTHER"));
}
#[test]
fn allows_filters_by_name_case_insensitively() {
let policy = ShellEnvironmentPolicy {
exclude: patterns(&["aws_*"]), // lowercase pattern, uppercase var
include_only: patterns(&["PATH", "HOME"]),
..Default::default()
};
assert!(policy.allows("PATH"));
assert!(!policy.allows("AWS_SECRET")); // excluded (case-insensitive)
assert!(!policy.allows("OTHER")); // not in include_only
let scrub = ShellEnvironmentPolicy {
ignore_default_excludes: false,
..Default::default()
};
assert!(!scrub.allows("my_api_key")); // `*KEY*` matches case-insensitively
assert!(ShellEnvironmentPolicy::default().allows("MY_API_KEY")); // default allows all
}
#[test]
fn allows_with_inherit_honors_inherit() {
// inherit = none admits nothing.
let none = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
..Default::default()
};
assert!(!none.allows_with_inherit("PATH"));
assert!(!none.allows_with_inherit("FOO"));
// inherit = core admits only core names.
let core = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::Core,
..Default::default()
};
assert!(core.allows_with_inherit("PATH"));
assert!(!core.allows_with_inherit("RANDOM_VAR"));
// inherit = all defers to `allows` (exclude still applies).
let all = ShellEnvironmentPolicy {
exclude: patterns(&["AWS_*"]),
..Default::default()
};
assert!(all.allows_with_inherit("RANDOM_VAR"));
assert!(!all.allows_with_inherit("AWS_SECRET"));
}