Synced from monorepo

Synced from monorepo

Changes:
- Workspace task snapshots only list incomplete backgrounded tasks
- Quiet auth, LSP, and config warnings in the shell
- Fix observability attributes for warm store errors, restore setup, remote tools, and preview denials
- Fail closed when soak metrics are missing
- Run plan-mode exit last in mixed tool batches
- Allow /loop to store prompts that can terminate the loop
- Make subagent maximum nesting depth configurable
- Security: apply sandbox profile to the leader process that executes tools

Source-Revision: 1adcd1f477870e4a97bacbd6be78c8a3bfbac46d
This commit is contained in:
grokkybara[bot] 2026-07-27 17:54:34 +00:00
commit 02d9359435
96 changed files with 2346 additions and 351 deletions

View file

@ -835,6 +835,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: owner.map(|s| s.to_string()),
description: None,
is_backgrounded: false,
}
}

View file

@ -444,6 +444,7 @@ impl ProcessState {
kind: self.kind,
owner_session_id: self.owner_session_id.clone(),
description: self.description.clone(),
is_backgrounded: self.bg_status.is_backgrounded(),
}
}
}
@ -1617,6 +1618,7 @@ impl LocalTerminalActor {
explicitly_killed: p.explicitly_killed,
owner_session_id: p.owner_session_id.clone(),
description: p.description.clone(),
is_backgrounded: true,
};
self.completed_task_snapshots.insert(id.clone(), snapshot);
}

View file

@ -219,6 +219,9 @@ pub struct TaskSnapshot {
/// Model-supplied label for task UI / snapshots.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// True after explicit/user/auto backgrounding; false for pure foreground runs.
#[serde(default)]
pub is_backgrounded: bool,
}
impl TaskSnapshot {
@ -238,6 +241,11 @@ impl TaskSnapshot {
pub fn is_outstanding(&self) -> bool {
!self.completed
}
/// Incomplete and backgrounded — tray/`tasks_snapshot` predicate (not FG in-flight).
pub fn is_outstanding_background(&self) -> bool {
!self.completed && self.is_backgrounded
}
}
/// Result of killing a terminal task.

View file

@ -50,7 +50,8 @@ pub use lsp::LspTool;
pub use monitor::tool::MonitorTool;
pub use read_file::ReadFileTool;
pub use scheduler::create::{
SCHEDULER_CREATE_TOOL_NAME, SchedulerCreateTool, loop_schedule_instruction, loop_usage_message,
LoopFireMode, SCHEDULER_CREATE_TOOL_NAME, SchedulerCreateTool, loop_schedule_instruction,
loop_usage_message,
};
pub use scheduler::delete::{SCHEDULER_DELETE_TOOL_NAME, SchedulerDeleteTool};
pub use scheduler::list::SchedulerListTool;

View file

@ -110,6 +110,53 @@ impl MonitorInput {
}
}
// Mid-turn monitor event buffer
/// A monitor event notification to be surfaced as a `<system-reminder>` mid-turn.
#[derive(Debug, Clone)]
pub struct MonitorEventNotification {
pub task_id: String,
pub event_text: String,
/// Session that owns the monitor which produced this event.
///
/// In leader mode every session shares one [`MonitorEventBuffer`], so the
/// drain sites filter on this to avoid surfacing one session's monitor
/// events inside another session's turn. `None` for legacy / non-grok-build
/// backends, which any session drains for backwards compatibility.
pub owner_session_id: Option<String>,
}
impl MonitorEventNotification {
/// Whether this buffered event should surface in the session whose owner id
/// is `my_owner`. Mirrors `task_owned_by_session`: an event surfaces only
/// when it has no recorded owner (legacy) or its owner matches the draining
/// session. Foreign events stay buffered for their own session to drain.
pub fn owned_by_session(&self, my_owner: Option<&str>) -> bool {
match (my_owner, self.owner_session_id.as_deref()) {
(Some(me), Some(owner)) => me == owner,
_ => true,
}
}
}
/// Shared buffer for mid-turn monitor event notifications: an [`EventQueue`]
/// of [`MonitorEventNotification`]. Producers `push_capped`; the turn loop
/// drains its session's events via [`drain_owned`].
///
/// [`EventQueue`]: xai_interjection_core::EventQueue
pub type MonitorEventBuffer = xai_interjection_core::EventQueue<MonitorEventNotification>;
crate::register_resource!("grok_build", "MonitorEventBuffer", MonitorEventBuffer);
/// Drain only `my_owner`'s events (the buffer is shared across sessions in
/// leader mode); owner-less legacy events drain anywhere.
pub fn drain_owned(
buffer: &MonitorEventBuffer,
my_owner: Option<&str>,
) -> Vec<MonitorEventNotification> {
buffer.drain_matching(|e| e.owned_by_session(my_owner))
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -8,7 +8,7 @@ use super::types::{ScheduledTask, SchedulerCommand, SchedulerHandle, scheduler_t
// Canonical /loop wording lives in the light API crate so other consumers can
// link it without the tools implementation crate; re-exported to keep paths stable.
pub use xai_grok_tools_api::slash_commands::{
SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
LoopFireMode, SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
@ -475,7 +475,7 @@ mod tests {
#[test]
fn loop_schedule_instruction_holds_invariants() {
let args = "every 30 minutes do x";
let instr = loop_schedule_instruction(args);
let instr = loop_schedule_instruction(args, LoopFireMode::Detached);
assert!(
!instr.contains("10m"),
"instruction must not default: {instr}"

View file

@ -14,7 +14,6 @@ use super::types::{
};
/// Cap on retained completed-subagent entries before the oldest are evicted.
/// Public so the subagent soak test can assert the coordinator stays bounded.
pub const MAX_COMPLETED_ENTRIES: usize = 1024;
pub(super) const OUTPUT_UNAVAILABLE_PLACEHOLDER: &str = "[subagent output no longer available]";

View file

@ -9,6 +9,7 @@
//!
//! - `SubagentBackendResource` — backend for spawn/query/cancel (required)
//! - `SubagentDepthCounter` — current nesting depth (optional, defaults to 0)
//! - `MaxSubagentDepth` — max nesting (optional, defaults to [`MAX_SUBAGENT_DEPTH`])
//! - `SessionIdResource` — current session ID for parent scoping (optional)
//! - `SubagentForegroundWait` — host wait-window guard factory (optional)
//! - `TaskModelValidator` — validates explicit model slugs before spawn
@ -30,10 +31,16 @@ use crate::types::resources::SharedResources;
use crate::types::tool::{ToolKind, ToolNamespace};
use xai_tool_types::{SubagentCompletedOutput, SubagentIsolationMode, TaskToolInput};
/// Maximum nesting depth for subagents. A top-level session is depth 0;
/// the first subagent is depth 1. Subagents cannot spawn further subagents.
/// Default max nesting depth when [`MaxSubagentDepth`] is not injected.
pub const MAX_SUBAGENT_DEPTH: u32 = 1;
pub fn effective_max_subagent_depth(resources: &crate::types::resources::Resources) -> u32 {
resources
.get::<MaxSubagentDepth>()
.map(|d| d.0)
.unwrap_or(MAX_SUBAGENT_DEPTH)
}
// ───────────────────────────────────────────────────────────────────────────
// Tool implementation
// ───────────────────────────────────────────────────────────────────────────
@ -125,10 +132,19 @@ impl xai_tool_runtime::Tool for TaskTool {
.map(|cancellation| cancellation.0.clone());
// 1. Depth check
let (depth, backend, model_validator, parent_session_id, parent_prompt_id, foreground_wait) = {
let (
depth,
max_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);
let max_depth = effective_max_subagent_depth(&res);
let backend = res
.get::<SubagentBackendResource>()
@ -155,6 +171,7 @@ impl xai_tool_runtime::Tool for TaskTool {
(
depth,
max_depth,
backend,
model_validator,
parent_session_id,
@ -163,9 +180,9 @@ impl xai_tool_runtime::Tool for TaskTool {
)
};
if depth >= MAX_SUBAGENT_DEPTH {
if depth >= max_depth {
return Err(xai_tool_runtime::ToolError::invalid_arguments(format!(
"Subagent depth limit exceeded (current depth: {depth}, max: {MAX_SUBAGENT_DEPTH}). \
"Subagent depth limit exceeded (current depth: {depth}, max: {max_depth}). \
Cannot spawn further nested subagents."
)));
}
@ -558,6 +575,41 @@ mod tests {
assert!(err.contains("depth limit exceeded"), "error: {err}");
}
#[tokio::test]
async fn raised_max_depth_allows_nested_spawn() {
let (backend, mut rx) = make_backend();
let mut resources = Resources::new();
resources.insert(backend);
resources.insert(SubagentDepthCounter(1));
resources.insert(MaxSubagentDepth(2));
resources.insert(SessionIdResource("child-session".to_string()));
resources.insert(CurrentPromptIdResource("prompt-nested".to_string()));
let result = xai_tool_runtime::Tool::run(
&TaskTool,
test_ctx(resources.into_shared()),
TaskToolInput {
description: "nested ok".into(),
prompt: "should be allowed at max_depth=2".into(),
subagent_type: "explore".into(),
run_in_background: true,
capability_mode: None,
isolation: None,
resume_from: None,
cwd: None,
model: None,
task_id: None,
},
)
.await;
assert!(
result.is_ok(),
"expected Ok at depth 1 with max 2: {result:?}"
);
let _ = rx.try_recv();
}
#[tokio::test]
async fn subagent_cannot_spawn_nested_subagent() {
let (backend, _rx) = make_backend();

View file

@ -10,7 +10,8 @@
//!
//! - `SubagentBackendResource` — wraps an `Arc<dyn SubagentBackend>` that
//! abstracts spawn/query/cancel (see [`super::backend`])
//! - `SubagentDepthCounter` — tracks nesting depth (max 1, no recursive spawning)
//! - `SubagentDepthCounter` — current nesting depth
//! - `MaxSubagentDepth` — configured max nesting depth
//! - `SessionIdResource` — carries the current session ID for parent scoping
//! - `TaskModelValidator` — validates explicit model slugs before background spawn
//!
@ -877,51 +878,6 @@ pub struct SubagentEventSender(#[educe(Debug(ignore))] pub mpsc::UnboundedSender
register_resource!("grok_build", "SubagentEventSender", SubagentEventSender);
// Mid-turn monitor event buffer
/// A monitor event notification to be surfaced as a `<system-reminder>` mid-turn.
#[derive(Debug, Clone)]
pub struct MonitorEventNotification {
pub task_id: String,
pub event_text: String,
/// Session that owns the monitor which produced this event.
///
/// In leader mode every session shares one [`MonitorEventBuffer`], so the
/// drain sites filter on this to avoid surfacing one session's monitor
/// events inside another session's turn. `None` for legacy / non-grok-build
/// backends, which any session drains for backwards compatibility.
pub owner_session_id: Option<String>,
}
impl MonitorEventNotification {
/// Whether this buffered event should surface in the session whose owner id
/// is `my_owner`. Mirrors `task_owned_by_session`: an event surfaces only
/// when it has no recorded owner (legacy) or its owner matches the draining
/// session. Foreign events stay buffered for their own session to drain.
pub fn owned_by_session(&self, my_owner: Option<&str>) -> bool {
match (my_owner, self.owner_session_id.as_deref()) {
(Some(me), Some(owner)) => me == owner,
_ => true,
}
}
}
/// Shared buffer for mid-turn monitor event notifications: an [`EventQueue`]
/// of [`MonitorEventNotification`]. Producers `push_capped`; the turn loop
/// drains its session's events via [`drain_owned`].
pub type MonitorEventBuffer = xai_interjection_core::EventQueue<MonitorEventNotification>;
register_resource!("grok_build", "MonitorEventBuffer", MonitorEventBuffer);
/// Drain only `my_owner`'s events (the buffer is shared across sessions in
/// leader mode); owner-less legacy events drain anywhere.
pub fn drain_owned(
buffer: &MonitorEventBuffer,
my_owner: Option<&str>,
) -> Vec<MonitorEventNotification> {
buffer.drain_matching(|e| e.owned_by_session(my_owner))
}
// Active subagent listing (compaction)
/// Lightweight summary of a running subagent.
@ -952,15 +908,18 @@ pub struct SubagentListActiveRequest {
pub respond_to: oneshot::Sender<Vec<ActiveSubagentSummary>>,
}
/// Tracks nesting depth. Injected into child's Resources with depth+1.
///
/// Top-level sessions start at depth 0. Each child increments by 1.
/// `TaskTool` rejects spawns when `depth >= MAX_SUBAGENT_DEPTH`.
/// Current nesting depth (top-level = 0; child = parent + 1).
#[derive(Debug, Clone)]
pub struct SubagentDepthCounter(pub u32);
register_resource!("grok_build", "SubagentDepthCounter", SubagentDepthCounter);
/// Host-injected max nesting depth; absent → [`super::MAX_SUBAGENT_DEPTH`].
#[derive(Debug, Clone, Copy)]
pub struct MaxSubagentDepth(pub u32);
register_resource!("grok_build", "MaxSubagentDepth", MaxSubagentDepth);
/// Session-scoped validator for model-facing `Task.model` arguments.
///
/// Returns an error message for an invalid slug and `None` for a valid slug.

View file

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

View file

@ -1,7 +1,6 @@
use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::tool::{ToolKind, ToolNamespace};
use super::task::MAX_SUBAGENT_DEPTH;
use super::task::types::SubagentDepthCounter;
pub use xai_grok_tools_api::slash_commands::WORKFLOW_TOOL_NAME;
@ -237,7 +236,8 @@ impl xai_tool_runtime::Tool for WorkflowTool {
(depth, sender)
};
if depth >= MAX_SUBAGENT_DEPTH {
// Workflows stay top-level-only regardless of configurable subagent depth.
if depth > 0 {
return Err(xai_tool_runtime::ToolError::custom(
"workflow_depth_exceeded",
"Workflows can only be launched from a top-level session (subagents and \

View file

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

View file

@ -255,7 +255,7 @@ fn split_wrapped_monitor_event(event_text: &str) -> Option<(&str, &str)> {
/// Buffered `event_text` arrives pre-wrapped (`wrap_monitor_event`); it is
/// unwrapped via [`split_wrapped_monitor_event`] with verbatim fallback.
pub fn format_monitor_events(
events: &[crate::implementations::grok_build::task::types::MonitorEventNotification],
events: &[crate::implementations::grok_build::monitor::types::MonitorEventNotification],
task_output_name: Option<&str>,
) -> Option<String> {
use std::fmt::Write as _;
@ -278,7 +278,8 @@ pub fn format_monitor_events(
))
}
_ => {
type Event = crate::implementations::grok_build::task::types::MonitorEventNotification;
type Event =
crate::implementations::grok_build::monitor::types::MonitorEventNotification;
let mut groups: Vec<(&str, Vec<&Event>)> = Vec::new();
for event in events {
match groups.iter_mut().find(|(id, _)| *id == event.task_id) {
@ -802,6 +803,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("abc-123"));
@ -829,6 +831,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_monitor_completion(&task, Some("get_command_or_subagent_output"));
assert!(
@ -862,6 +865,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_monitor_completion(&task, None);
assert!(
@ -890,6 +894,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("cargo test"));
@ -915,6 +920,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("exit code: unknown"));
@ -943,6 +949,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(
@ -982,6 +989,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(
@ -1020,6 +1028,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
};
let msg = format_bash_completion(&task, Some("get_command_or_subagent_output"), None);
assert!(msg.contains("exit code: 0"));
@ -1181,6 +1190,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
}
}
fn make_running(id: &str) -> TaskSnapshot {
@ -1202,6 +1212,7 @@ mod tests {
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
}
}
fn make_bg_started(id: &str) -> crate::types::output::BackgroundTaskStarted {
@ -1811,7 +1822,7 @@ mod tests {
/// reintroduced.
#[tokio::test]
async fn reminder_pipeline_ignores_monitor_event_buffer() {
use crate::implementations::grok_build::task::types::{
use crate::implementations::grok_build::monitor::types::{
MonitorEventBuffer, MonitorEventNotification,
};
use crate::types::resources::Resources;
@ -1845,7 +1856,7 @@ mod tests {
/// own + owner-less legacy events; foreign events stay buffered.
#[test]
fn drain_owned_partitions_by_session_owner() {
use crate::implementations::grok_build::task::types::{
use crate::implementations::grok_build::monitor::types::{
MonitorEventBuffer, MonitorEventNotification, drain_owned,
};
let shared_buffer = MonitorEventBuffer::default();
@ -1882,7 +1893,7 @@ mod tests {
/// empty => `None`.
#[test]
fn format_monitor_events_single_vs_batched() {
use crate::implementations::grok_build::task::types::MonitorEventNotification;
use crate::implementations::grok_build::monitor::types::MonitorEventNotification;
let event = |task: &str, desc: &str, text: &str| MonitorEventNotification {
task_id: task.to_string(),
event_text: format!(
@ -1900,7 +1911,7 @@ mod tests {
single, "<monitor-event task_id=\"task-0\">\n[alpha] line 0\n</monitor-event>",
"single event must use the lean monitor-event form"
);
let bare = crate::implementations::grok_build::task::types::MonitorEventNotification {
let bare = crate::implementations::grok_build::monitor::types::MonitorEventNotification {
task_id: "task-9".into(),
event_text: "bare text, no wrapper".into(),
owner_session_id: None,
@ -1988,7 +1999,7 @@ mod tests {
/// End-to-end multibyte safety through the formatter (single + batch).
#[test]
fn format_monitor_events_handles_multibyte_content() {
use crate::implementations::grok_build::task::types::MonitorEventNotification;
use crate::implementations::grok_build::monitor::types::MonitorEventNotification;
let event = |task: &str, desc: &str, text: &str| MonitorEventNotification {
task_id: task.to_string(),
event_text: format!(