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:
parent
b41c75a578
commit
02d9359435
96 changed files with 2346 additions and 351 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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]";
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -917,6 +917,7 @@ pub(crate) mod test_helpers {
|
|||
explicitly_killed: false,
|
||||
owner_session_id: None,
|
||||
description: None,
|
||||
is_backgrounded: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ mod tests {
|
|||
explicitly_killed: false,
|
||||
owner_session_id: None,
|
||||
description: None,
|
||||
is_backgrounded: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue