Synced from monorepo

Changes:
- Stop hooks for session lifecycle
- Add x.ai/session/state and x.ai/session/import ACP methods
- Deny-and-continue for auto-mode classifier blocks with denial limits
- Drop codebase-upload from dhat soak test
- scheduler_create upsert via task_id; retire one-shot tasks
- Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal
- Polarity-safe syntax colors in minimal mode
- Auto mode classifies unvetted env prefixes instead of hard-prompting
- Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
This commit is contained in:
grokkybara[bot] 2026-07-19 18:40:33 +01:00
commit ba76b0a683
143 changed files with 9465 additions and 3419 deletions

View file

@ -400,8 +400,10 @@ impl acp::Agent for MvpAgent {
.meta(
serde_json::json!(
{ "x.ai/fs_notify" : true, "x.ai/hooks" : { "blockingEvents"
: [xai_grok_hooks::event::HookEventName::PreToolUse],
"decisions" : ["deny"], }, }
: crate ::extensions::hooks::ADVERTISED_BLOCKING_EVENTS,
"decisions" : crate
::extensions::hooks::ADVERTISED_DECISIONS, "stopSignals" :
crate ::extensions::hooks::ADVERTISED_STOP_SIGNALS, }, }
)
.as_object()
.cloned(),
@ -1975,12 +1977,7 @@ impl acp::Agent for MvpAgent {
#[tracing::instrument(
name = "agent.prompt",
skip_all,
fields(
session_id = %arguments.session_id.0,
turn_number = tracing::field::Empty,
uploads_enabled = tracing::field::Empty,
upload_reason = tracing::field::Empty,
)
fields(session_id = %arguments.session_id.0, turn_number = tracing::field::Empty)
)]
#[allow(unused_mut)]
async fn prompt(
@ -3187,6 +3184,12 @@ impl acp::Agent for MvpAgent {
"x.ai/session/updates" => {
crate::extensions::session_updates::handle(&args, &self.gateway).await
}
"x.ai/session/state" => {
crate::extensions::session_state::handle_state(&args).await
}
"x.ai/session/import" => {
crate::extensions::session_state::handle_import(&args).await
}
"x.ai/session/load_history" => {
crate::extensions::chat_conversation_history::handle(self, &args).await
}

View file

@ -2170,8 +2170,7 @@ impl MvpAgent {
)
}
/// Like `trace_upload_config`, but also returns the reason why uploads
/// are enabled/disabled. Used by `get_trace_context` to record
/// `upload_reason` on the `agent.prompt` span.
/// are enabled or disabled for structured session events.
async fn trace_upload_config_with_reason(
&self,
) -> (
@ -2602,7 +2601,6 @@ impl MvpAgent {
let (upload_method, upload_reason) = self
.trace_upload_config_with_reason()
.await;
tracing::Span::current().record("upload_reason", upload_reason.as_str());
{
let mut decision = self.cfg.borrow().trace_upload_decision_debug();
if let Some(obj) = decision.as_object_mut() {
@ -2627,12 +2625,8 @@ impl MvpAgent {
);
}
let upload_method = match upload_method {
Some(method) => {
tracing::Span::current().record("uploads_enabled", true);
method
}
Some(method) => method,
None => {
tracing::Span::current().record("uploads_enabled", false);
xai_grok_telemetry::session_ctx::log_session_event(crate::agent::session_metrics::TraceUploadSkipped {
session_id: session_info.id.0.to_string(),
turn_number,
@ -2648,7 +2642,6 @@ impl MvpAgent {
match cfg.endpoints.resolve_trace_bucket_url() {
Some(resolved) => Some(resolved.value),
None => {
tracing::Span::current().record("uploads_enabled", false);
xai_grok_telemetry::session_ctx::log_session_event(crate::agent::session_metrics::TraceUploadSkipped {
session_id: session_info.id.0.to_string(),
turn_number,
@ -2676,12 +2669,6 @@ impl MvpAgent {
let session_handle = match self.sessions.borrow().get(&session_info.id) {
Some(h) => h.clone(),
None => {
tracing::Span::current().record("uploads_enabled", false);
tracing::Span::current()
.record(
"upload_reason",
crate::upload::turn::TraceUploadReason::SessionNotFound.as_str(),
);
return None;
}
};

View file

@ -25,12 +25,42 @@ impl MvpAgent {
while let Some(event) = rx.recv().await {
match event {
SubagentEvent::Spawn(boxed) => {
let request = *boxed;
let mut request = *boxed;
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
let this = agent_ref.get();
let parent_is_session = this.sessions.borrow().contains_key(
&acp::SessionId::new(request.parent_session_id.clone()),
);
if !parent_is_session
&& let Some(root) = this
.subagent_coordinator
.borrow()
.parent_of_child_session(&request.parent_session_id)
{
tracing::info!(
child_session_id = % request.parent_session_id,
root_session_id = % root, subagent_id = % request.id,
"Re-parenting child-session spawn to root session"
);
request.parent_session_id = root;
request.surface_completion = false;
}
let parent_sid = request.parent_session_id.clone();
let mut ctx = this.build_subagent_spawn_context(&parent_sid);
let Some(mut ctx) =
this.try_build_subagent_spawn_context(&parent_sid)
else {
tracing::warn!(
parent_session_id = % parent_sid, subagent_id = % request
.id,
"Spawn for unknown/evicted parent session, failing request"
);
crate::agent::subagent::send_failure(
request,
"Parent session not found (evicted or torn down); cannot spawn subagent.",
);
return;
};
let parent_handle = {
let parent_sid_acp = acp::SessionId::new(parent_sid.clone());
this.sessions.borrow().get(&parent_sid_acp).cloned()
@ -283,16 +313,12 @@ impl MvpAgent {
cli_agent_names,
}
}
/// Build a `SubagentSpawnContext` from the current agent state and the
/// parent session's shared resources.
///
/// This is the ONLY subagent-related method on MvpAgent besides the
/// coordinator startup.
/// Build a spawn context for a real subagent spawn. The parent session is
/// guaranteed present here because the parent just issued the spawn request,
/// so a missing parent is a real invariant violation and panics. Read-only
/// callers that can race a parent teardown (e.g. `DescribeType`) must use
/// [`Self::try_build_subagent_spawn_context`] instead.
/// Test-only infallible wrapper around
/// [`Self::try_build_subagent_spawn_context`]. Production spawn paths use
/// the fallible variant and fail the request when the parent session is
/// absent (evicted, or a child-session spawn whose re-parent lookup
/// missed).
#[cfg(test)]
pub(super) fn build_subagent_spawn_context(
&self,
parent_session_id: &str,
@ -300,10 +326,13 @@ impl MvpAgent {
self.try_build_subagent_spawn_context(parent_session_id)
.expect("parent session must exist when spawning subagents")
}
/// Fallible variant of [`Self::build_subagent_spawn_context`]: returns
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
/// instead of panicking, so read-only paths that can race a teardown can
/// fail open.
/// Build a `SubagentSpawnContext` from the current agent state and the
/// parent session's shared resources. Returns `None` when the parent
/// `SessionHandle` is absent (evicted / torn down) so callers can fail
/// the request instead of panicking.
///
/// This is the ONLY subagent-related method on MvpAgent besides the
/// coordinator startup.
pub(super) fn try_build_subagent_spawn_context(
&self,
parent_session_id: &str,

View file

@ -1,15 +1,25 @@
//! Heap-leak test for the session lifecycle: create and remove many sessions,
//! then fail if heap memory grows per session. Run:
//! cargo test -p xai-grok-shell --features dhat-heap \
//! leader_session_lifecycle_heap_steady_state -- --ignored --nocapture
use super::*;
use xai_grok_workspace::permission::PermissionEvent;
// Chosen between a healthy build (about zero retained allocations per
// session) and the smallest deliberately introduced leak (one per session);
// re-tune if healthy runs drift toward the limits.
const MAX_BLOCKS_PER_SESSION: f64 = 0.5;
const MAX_BYTES_PER_SESSION: f64 = 1024.0;
/// Creates the per-session state that `remove_session` must clean up, then
/// removes the session. A full `SessionHandle` would allocate so much
/// unrelated memory that a small leak would be lost in the noise.
fn populate_and_evict(agent: &MvpAgent, i: usize) {
let sid = acp::SessionId::new(format!("soak-{i}"));
// The same workspace binding `spawn_session_actor` creates; if
// `remove_session` does not release it, the session map holds every
// toolset for the life of the process.
{
let ops = agent.workspace_ops.borrow();
let ops = ops.as_ref().expect("test installs workspace ops");
@ -25,6 +35,7 @@ fn populate_and_evict(agent: &MvpAgent, i: usize) {
)
.expect("bind_local_session must succeed");
}
let (_ptx, prx) = tokio::sync::mpsc::unbounded_channel::<PermissionEvent>();
agent
.permission_event_receivers
@ -38,8 +49,10 @@ fn populate_and_evict(agent: &MvpAgent, i: usize) {
sid.0.to_string(),
acp::ModelId::new(std::sync::Arc::from("gone-model")),
);
agent.remove_session(&sid);
}
/// Waits for background tasks to finish before reading heap stats.
async fn quiesce() {
const YIELD_ROUNDS: usize = 50;
@ -52,43 +65,58 @@ async fn quiesce() {
tokio::task::yield_now().await;
}
}
/// Creating and removing N sessions must not grow the heap.
///
/// Only one `dhat::Profiler` can exist at a time, and the test harness runs
/// tests in parallel, so keep this the only test that creates one.
#[test]
#[ignore = "heap soak; nightly only, needs --features dhat-heap"]
fn leader_session_lifecycle_heap_steady_state() {
run_local_for_bridge_test(|| async {
let agent = build_minimal_agent_for_tests();
*agent.workspace_ops.borrow_mut() = Some(xai_grok_workspace::WorkspaceOps::for_test());
let _profiler = dhat::Profiler::builder().testing().build();
const WARMUP: usize = 16;
const MEASURE: usize = 256;
// The first runs fill caches and one-time allocations; do them before
// the measured window so they do not count as growth.
for i in 0..WARMUP {
populate_and_evict(&agent, i);
}
quiesce().await;
let before = dhat::HeapStats::get();
for i in WARMUP..(WARMUP + MEASURE) {
populate_and_evict(&agent, i);
}
quiesce().await;
let after = dhat::HeapStats::get();
let d_blocks = after.curr_blocks as i64 - before.curr_blocks as i64;
let d_bytes = after.curr_bytes as i64 - before.curr_bytes as i64;
let blocks_per = d_blocks as f64 / MEASURE as f64;
let bytes_per = d_bytes as f64 / MEASURE as f64;
// Printed before the asserts so failing runs still show the numbers.
eprintln!(
"DHAT_SOAK_SUMMARY {}",
serde_json::json!({ "warmup_sessions" : WARMUP,
"measured_sessions" : MEASURE, "before_blocks" : before.curr_blocks,
"before_bytes" : before.curr_bytes, "after_blocks" : after.curr_blocks,
"after_bytes" : after.curr_bytes, "blocks_per_session" : blocks_per,
"bytes_per_session" : bytes_per, "max_blocks_per_session" :
MAX_BLOCKS_PER_SESSION, "max_bytes_per_session" : MAX_BYTES_PER_SESSION,
"pass" : blocks_per < MAX_BLOCKS_PER_SESSION && bytes_per <
MAX_BYTES_PER_SESSION })
serde_json::json!({
"warmup_sessions": WARMUP,
"measured_sessions": MEASURE,
"before_blocks": before.curr_blocks,
"before_bytes": before.curr_bytes,
"after_blocks": after.curr_blocks,
"after_bytes": after.curr_bytes,
"blocks_per_session": blocks_per,
"bytes_per_session": bytes_per,
"max_blocks_per_session": MAX_BLOCKS_PER_SESSION,
"max_bytes_per_session": MAX_BYTES_PER_SESSION,
"pass": blocks_per < MAX_BLOCKS_PER_SESSION && bytes_per < MAX_BYTES_PER_SESSION
})
);
assert!(
blocks_per < MAX_BLOCKS_PER_SESSION,
"block-count leak: {blocks_per:.3} blocks/session retained ({d_blocks} over {MEASURE} cycles) exceeds the {MAX_BLOCKS_PER_SESSION} gate"

View file

@ -323,6 +323,7 @@ impl SubagentCoordinator {
effective_model_id: String::new(),
block_waited: false,
explicitly_killed: false,
completion_output_cap: None,
persisted_output_dir: None,
},
);
@ -386,6 +387,9 @@ impl SubagentCoordinator {
let block_waited = tracker.as_ref().is_some_and(|t| t.block_waited);
let explicitly_killed = tracker.as_ref().is_some_and(|t| t.explicitly_killed);
let surface_completion = tracker.as_ref().is_none_or(|t| t.surface_completion);
let completion_output_cap = tracker
.as_ref()
.and_then(|t| t.completion_output_cap);
let mut completed = CompletedSubagent {
subagent_id: id.to_string(),
parent_session_id,
@ -404,6 +408,7 @@ impl SubagentCoordinator {
effective_model_id,
block_waited,
explicitly_killed,
completion_output_cap,
persisted_output_dir,
};
let success = completed.result.success && !completed.result.cancelled;
@ -440,7 +445,10 @@ impl SubagentCoordinator {
duration_ms: completed.result.duration_ms,
tool_calls: completed.result.tool_calls,
turns: completed.result.turns,
output: completed.result.output.clone(),
output: super::cap_completion_output(
&completed.result.output,
completed.completion_output_cap,
),
});
}
if completed.persisted_output_dir.is_some() {

View file

@ -106,6 +106,19 @@ impl SubagentCoordinator {
}
None
}
/// Parent session of the running subagent whose child session is
/// `child_session_id`. Used to re-parent spawn requests that originate
/// inside a child session (e.g. a loop iteration spawning its own
/// subagent) to the root session that owns it.
pub(crate) fn parent_of_child_session(
&self,
child_session_id: &str,
) -> Option<String> {
self.active
.values()
.find(|t| t.child_session_id.0.as_ref() == child_session_id)
.map(|t| t.parent_session_id.clone())
}
/// Return `(parent_session_id, child_session_id)` for a given subagent.
///
/// Checks active first, then completed. Returns `None` if not found.

View file

@ -25,6 +25,24 @@ use xai_grok_tools::implementations::grok_build::task::types::*;
use xai_grok_workspace::file_system::AsyncFileSystem;
use xai_hunk_tracker::HunkTrackerHandle;
use super::*;
/// Remove the task tool (and orphaned background-task actions) from a child
/// toolset at or beyond `MAX_SUBAGENT_DEPTH`. Returns whether the task tool
/// was removed.
pub(super) fn strip_task_tools_at_max_depth(
tool_config: &mut xai_grok_tools::registry::types::ToolServerConfig,
child_depth: u32,
) -> bool {
use xai_grok_tools::implementations::grok_build::task::MAX_SUBAGENT_DEPTH;
use xai_grok_tools::types::tool::ToolKind;
if child_depth < MAX_SUBAGENT_DEPTH {
return false;
}
let before = tool_config.tools.len();
tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task));
let stripped = tool_config.tools.len() < before;
prune_orphaned_background_task_tools(tool_config);
stripped
}
pub(super) fn task_model_override_error(
requested: Option<&str>,
provenance: ModelOverrideProvenance,
@ -401,21 +419,15 @@ pub(crate) async fn handle_subagent_request(
"Applied capability mode filter to agent tool config"
);
}
{
use xai_grok_tools::implementations::grok_build::task::MAX_SUBAGENT_DEPTH;
use xai_grok_tools::types::tool::ToolKind;
let child_depth = ctx.parent_depth + 1;
if child_depth >= MAX_SUBAGENT_DEPTH {
let before = definition.tool_config.tools.len();
definition.tool_config.tools.retain(|tc| tc.kind != Some(ToolKind::Task));
if definition.tool_config.tools.len() < before {
tracing::info!(
subagent_id = % request.id, child_depth, max_depth =
MAX_SUBAGENT_DEPTH, "Stripped task tool from child at max depth"
);
}
prune_orphaned_background_task_tools(&mut definition.tool_config);
}
let child_depth = request
.runtime_overrides
.spawn_depth
.unwrap_or(ctx.parent_depth + 1);
if strip_task_tools_at_max_depth(&mut definition.tool_config, child_depth) {
tracing::info!(
subagent_id = % request.id, child_depth,
"Stripped task tool from child at max depth"
);
}
if request.fork_context {
effective_runtime.model = Some(ctx.model_id.0.to_string());
@ -624,7 +636,7 @@ pub(crate) async fn handle_subagent_request(
.capability_mode
.as_ref()
.map(|m| format!("{m:?}")),
depth: ctx.parent_depth + 1,
depth: child_depth,
};
emit_subagent_notification(
gateway,
@ -748,7 +760,7 @@ pub(crate) async fn handle_subagent_request(
.with_hunk_tracking_enabled(ctx.hunk_tracking_enabled);
tool_ctx.subagent_event_tx = Some(ctx.subagent_event_tx.clone());
tool_ctx.monitor_event_buffer = Some(MonitorEventBuffer::default());
tool_ctx.subagent_depth = ctx.parent_depth + 1;
tool_ctx.subagent_depth = child_depth;
tool_ctx.lsp = ctx.lsp.clone();
let parent_traceparent = xai_file_utils::trace_context::current_traceparent();
let tracker_child_cwd = child_session_info.cwd.clone();
@ -1247,6 +1259,7 @@ pub(crate) async fn handle_subagent_request(
effective_model_id: tracker_model_id,
run_in_background,
surface_completion: request.surface_completion,
completion_output_cap: request.runtime_overrides.completion_output_cap,
color: tracker_color,
block_waited: false,
explicitly_killed: false,

View file

@ -84,6 +84,7 @@ pub(crate) struct SubagentTracker {
pub run_in_background: bool,
/// Mirrors `SubagentRequest::surface_completion`.
pub surface_completion: bool,
pub completion_output_cap: Option<usize>,
/// Set when a `block=true` waiter consumed this subagent's result.
pub block_waited: bool,
/// Set when the model explicitly killed this subagent via the kill tool.
@ -133,7 +134,6 @@ impl AutoCompactThresholdTiers {
}
}
/// Everything the coordinator needs from MvpAgent to spawn a child session.
///
/// Avoids passing `&MvpAgent` (which would require the coordinator to know
/// about the full agent struct). Built by `MvpAgent::build_subagent_spawn_context()`.
pub(crate) struct SubagentSpawnContext {
@ -501,6 +501,7 @@ pub(crate) struct CompletedSubagent {
pub block_waited: bool,
/// Set when the model explicitly killed this subagent via the kill tool.
pub explicitly_killed: bool,
pub completion_output_cap: Option<usize>,
/// Directory whose `output.json` holds the output text; when set, the
/// stored `result.output` is cleared and `lookup` reads from disk.
/// `None` (failures, empty outputs, failed writes) serves from memory.
@ -508,6 +509,26 @@ pub(crate) struct CompletedSubagent {
/// `meta.json`, and trace upload carries the text to GCS.
pub persisted_output_dir: Option<PathBuf>,
}
pub(crate) fn cap_completion_output(
output: &std::sync::Arc<str>,
cap: Option<usize>,
) -> std::sync::Arc<str> {
match cap {
Some(cap) if output.len() > cap => {
let mut end = cap;
while end > 0 && !output.is_char_boundary(end) {
end -= 1;
}
std::sync::Arc::from(format!(
"{}\n[output truncated: {} of {} bytes shown]",
&output[..end],
end,
output.len()
))
}
_ => output.clone(),
}
}
/// Lightweight entry for subagents that have been requested but are still
/// initializing (creating worktree, resolving config, spawning session).
/// Promoted to a full `SubagentTracker` once the child session is ready.
@ -2024,7 +2045,10 @@ fn inject_subagent_completed_prompt(
duration_ms: result.duration_ms,
tool_calls: result.tool_calls,
turns: result.turns,
output: result.output.clone(),
output: cap_completion_output(
&result.output,
request.runtime_overrides.completion_output_cap,
),
};
let message = xai_grok_tools::reminders::task_completion::format_subagent_completion(
&summary,
@ -2079,7 +2103,7 @@ fn inject_subagent_completed_prompt(
}
/// Post-`insert_pending`, pre-`SubagentSpawned` failure: just send via oneshot;
/// `PendingGuard::drop` handles the queue side effects.
fn send_failure(request: SubagentRequest, error: &str) {
pub(crate) fn send_failure(request: SubagentRequest, error: &str) {
let _ = request.result_tx.send(SubagentResult {
success: false,
error: Some(error.to_string()),

View file

@ -924,6 +924,7 @@ fn completed_with_output(
effective_model_id: String::new(),
block_waited: false,
explicitly_killed: false,
completion_output_cap: None,
persisted_output_dir,
}
}
@ -1309,6 +1310,7 @@ fn dummy_tracker(
effective_model_id: String::new(),
run_in_background: false,
surface_completion: true,
completion_output_cap: None,
color: None,
block_waited: false,
explicitly_killed: false,
@ -1341,6 +1343,26 @@ async fn active_summaries_returns_all_regardless_of_parent() {
let all = coordinator.active_summaries();
assert_eq!(all.len(), 2);
}
/// Spawns issued from inside a child session (loop iterations) re-parent
/// to the root session via the running tracker's child→parent mapping.
#[tokio::test]
async fn parent_of_child_session_maps_to_root() {
let mut coordinator = SubagentCoordinator::new();
coordinator
.insert(
dummy_tracker(
"iter-child-sess",
"root-session",
"general-purpose",
"loop iteration",
),
);
assert_eq!(
coordinator.parent_of_child_session("iter-child-sess").as_deref(),
Some("root-session")
);
assert_eq!(coordinator.parent_of_child_session("unknown-sess"), None);
}
#[tokio::test]
async fn resolve_running_list_returns_empty_for_empty_seeds() {
let resolved = resolve_running_list(vec![]).await;

View file

@ -346,6 +346,7 @@ fn resumable_source_returns_info_for_completed_subagent() {
effective_model_id: "grok-3".into(),
block_waited: false,
explicitly_killed: false,
completion_output_cap: None,
persisted_output_dir: None,
},
);
@ -1358,6 +1359,7 @@ fn resumable_source_rejects_cross_session_lookup() {
effective_model_id: String::new(),
block_waited: false,
explicitly_killed: false,
completion_output_cap: None,
persisted_output_dir: None,
},
);
@ -2176,6 +2178,7 @@ fn completed_subagent_propagates_resumed_from() {
effective_model_id: "grok-3".into(),
block_waited: false,
explicitly_killed: false,
completion_output_cap: None,
persisted_output_dir: None,
},
);
@ -3308,3 +3311,23 @@ async fn progress_publisher_delivers_ticks_to_parent_cmd_channel() {
})
.await;
}
/// A harness-pinned `spawn_depth` of 0 (scheduler loop iterations) keeps
/// the task tool in the child toolset; a natural depth-1 child loses it.
#[test]
fn strip_task_tools_honors_spawn_depth() {
use xai_grok_agent::config::AgentDefinition;
use xai_grok_tools::registry::types::ToolServerConfig;
use xai_grok_tools::types::tool::ToolKind;
use super::super::handle_request::strip_task_tools_at_max_depth;
let has_task = |cfg: &ToolServerConfig| {
cfg.tools.iter().any(|tc| tc.kind == Some(ToolKind::Task))
};
let base = AgentDefinition::general_purpose().tool_config;
assert!(has_task(& base));
let mut natural_child = base.clone();
assert!(strip_task_tools_at_max_depth(& mut natural_child, 1));
assert!(! has_task(& natural_child));
let mut loop_iteration = base.clone();
assert!(! strip_task_tools_at_max_depth(& mut loop_iteration, 0));
assert!(has_task(& loop_iteration));
}