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:
parent
7cfcb20d2b
commit
ba76b0a683
143 changed files with 9465 additions and 3419 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue