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:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -73,7 +73,7 @@ struct ActivityInner {
|
|||
/// (see module docs), and are purged whenever the list is locked.
|
||||
sessions: Mutex<Vec<SessionActivityEntry>>,
|
||||
/// Subagents currently initializing or running; kept in sync by
|
||||
/// `SubagentCoordinator::sync_running_gauge`.
|
||||
/// the shared coordinator's `running_count_changed` callback.
|
||||
subagents: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
|
|
@ -95,8 +95,8 @@ impl AgentActivity {
|
|||
});
|
||||
}
|
||||
|
||||
/// Shared gauge of initializing + running subagents; handed to the
|
||||
/// `SubagentCoordinator`, which recomputes it on every state change.
|
||||
/// Shared gauge of initializing + running subagents; updated from the
|
||||
/// shared coordinator's lifecycle callback.
|
||||
pub(crate) fn subagent_gauge(&self) -> Arc<AtomicUsize> {
|
||||
self.inner.subagents.clone()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1002,10 +1002,20 @@ pub struct CliConfig {
|
|||
pub worktree_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_registry: Option<bool>,
|
||||
/// User-layer value; use [`crate::util::config::resolve_minimum_version`]
|
||||
/// for enforcement (semver-max across layers; managed floors can't be lowered).
|
||||
/// Env `GROK_MINIMUM_VERSION`. See [`crate::util::config::VersionPolicy`] for
|
||||
/// the version-policy knobs. (Unrelated to
|
||||
/// `version_overrides[].maximum_version`, which gates config patches.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub minimum_version: Option<String>,
|
||||
/// Env `GROK_MAXIMUM_VERSION`. See [`crate::util::config::VersionPolicy`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub maximum_version: Option<String>,
|
||||
/// Env `GROK_REQUIRED_MINIMUM_VERSION`. See [`crate::util::config::VersionPolicy`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required_minimum_version: Option<String>,
|
||||
/// Env `GROK_REQUIRED_MAXIMUM_VERSION`. See [`crate::util::config::VersionPolicy`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required_maximum_version: Option<String>,
|
||||
/// Group sessions by repo in the picker and CLI listings.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_picker_grouped: Option<bool>,
|
||||
|
|
@ -1317,6 +1327,19 @@ pub struct PermissionKnownKeys {
|
|||
/// Verbose `[[permission.rules]]` form.
|
||||
pub rules: Option<toml::Value>,
|
||||
}
|
||||
/// `[shell_environment_policy]` known keys, for the unrecognized-key scan only;
|
||||
/// the value is parsed at spawn by [`crate::util::config::resolve_shell_env_policy`].
|
||||
/// `Option<toml::Value>` (no `deny_unknown_fields`) keeps a typo a warning, not a
|
||||
/// load failure, like [`PermissionKnownKeys`].
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ShellEnvironmentPolicyKnownKeys {
|
||||
pub inherit: Option<toml::Value>,
|
||||
pub ignore_default_excludes: Option<toml::Value>,
|
||||
pub exclude: Option<toml::Value>,
|
||||
pub set: Option<toml::Value>,
|
||||
pub include_only: Option<toml::Value>,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub features: Features,
|
||||
|
|
@ -1358,6 +1381,9 @@ pub struct Config {
|
|||
pub ui: UiConfig,
|
||||
#[serde(default)]
|
||||
pub toolset: ShellToolsetConfig,
|
||||
/// Validation only; the value is parsed at spawn by `resolve_shell_env_policy`.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub shell_environment_policy: ShellEnvironmentPolicyKnownKeys,
|
||||
#[serde(default)]
|
||||
pub endpoints: EndpointsConfig,
|
||||
#[serde(default)]
|
||||
|
|
@ -1782,6 +1808,7 @@ impl Default for Config {
|
|||
hints: None,
|
||||
ui: UiConfig::default(),
|
||||
toolset: ShellToolsetConfig::default(),
|
||||
shell_environment_policy: ShellEnvironmentPolicyKnownKeys::default(),
|
||||
endpoints,
|
||||
telemetry: TelemetryConfig::default(),
|
||||
session: SessionConfig::default(),
|
||||
|
|
@ -2607,6 +2634,17 @@ impl Config {
|
|||
)
|
||||
.map(|r| r.value)
|
||||
}
|
||||
pub(crate) fn resolve_image_edit_model_override(&self) -> Option<String> {
|
||||
resolve_string_flag(
|
||||
None,
|
||||
"GROK_IMAGE_EDIT_MODEL_OVERRIDE",
|
||||
self.features.image_edit_model_override.as_deref(),
|
||||
self.remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.image_edit_model_override.as_deref()),
|
||||
)
|
||||
.map(|r| r.value)
|
||||
}
|
||||
/// Goal mode (`/goal`) master switch. Default ON: deployments that can't
|
||||
/// reach cli-chat-proxy `/v1/settings` (custom `models_base_url`, external
|
||||
/// `auth_provider_command`, air-gapped proxies) never receive the
|
||||
|
|
@ -3938,6 +3976,10 @@ pub struct ConfigModelOverride {
|
|||
pub api_backend: Option<ApiBackend>,
|
||||
#[serde(default)]
|
||||
pub extra_headers: IndexMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub query_params: IndexMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub env_http_headers: IndexMap<String, String>,
|
||||
pub context_window: Option<u64>,
|
||||
/// Per-model auto-compact threshold override (0-100) from `[model.<id>]`.
|
||||
/// Read directly by `resolve_auto_compact_threshold_percent`; intentionally
|
||||
|
|
@ -4002,6 +4044,12 @@ impl ConfigModelOverride {
|
|||
if !self.extra_headers.is_empty() {
|
||||
entry.info.extra_headers = self.extra_headers.clone();
|
||||
}
|
||||
if !self.query_params.is_empty() {
|
||||
entry.info.query_params = self.query_params.clone();
|
||||
}
|
||||
if !self.env_http_headers.is_empty() {
|
||||
entry.info.env_http_headers = self.env_http_headers.clone();
|
||||
}
|
||||
if let Some(cw) = self.context_window.and_then(NonZeroU64::new) {
|
||||
entry.info.context_window = cw;
|
||||
}
|
||||
|
|
@ -4093,6 +4141,10 @@ pub struct ModelInfo {
|
|||
pub api_backend: ApiBackend,
|
||||
pub auth_scheme: AuthScheme,
|
||||
pub extra_headers: IndexMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub query_params: IndexMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub env_http_headers: IndexMap<String, String>,
|
||||
pub context_window: NonZeroU64,
|
||||
/// Per-model auto-compact threshold (0-100). `None` defers to the
|
||||
/// global / default tiers in `resolve_auto_compact_threshold_percent`.
|
||||
|
|
@ -4158,6 +4210,8 @@ impl ModelInfo {
|
|||
api_backend: ApiBackend::default(),
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||||
auto_compact_threshold_percent: None,
|
||||
system_prompt_label: None,
|
||||
|
|
@ -4193,6 +4247,8 @@ impl ModelInfo {
|
|||
api_backend: entry.api_backend.clone(),
|
||||
auth_scheme: entry.auth_scheme.unwrap_or_default(),
|
||||
extra_headers: entry.extra_headers.clone(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: entry.context_window,
|
||||
auto_compact_threshold_percent: entry.auto_compact_threshold_percent,
|
||||
system_prompt_label: entry.system_prompt_label.clone(),
|
||||
|
|
@ -4548,6 +4604,8 @@ pub struct Features {
|
|||
/// (`image_gen_model_override`) / env / default (`grok-imagine-image-quality`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_gen_model_override: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_edit_model_override: Option<String>,
|
||||
/// Write file tool. `None` = defer to remote settings / env / default (true).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub write_file: Option<bool>,
|
||||
|
|
@ -4931,6 +4989,8 @@ pub fn resolve_aux_model_sampling_config(
|
|||
api_backend: ApiBackend::Responses,
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||||
auto_compact_threshold_percent: None,
|
||||
system_prompt_label: None,
|
||||
|
|
@ -5066,6 +5126,8 @@ pub fn sampling_config_for_model(
|
|||
api_backend,
|
||||
auth_scheme: credentials.auth_scheme,
|
||||
extra_headers,
|
||||
query_params: info.query_params.clone(),
|
||||
env_http_headers: info.env_http_headers.clone(),
|
||||
context_window: info.context_window.get(),
|
||||
client_version,
|
||||
reasoning_effort: info.reasoning_effort,
|
||||
|
|
@ -5160,6 +5222,8 @@ fn resolve_hidden_default_web_search_sampling_config(
|
|||
api_backend: ApiBackend::Responses,
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||||
auto_compact_threshold_percent: None,
|
||||
system_prompt_label: None,
|
||||
|
|
@ -5776,6 +5840,7 @@ reasoning_effort = "low"
|
|||
args: None,
|
||||
token_ttl_secs: Some(3600),
|
||||
timeout_secs: None,
|
||||
cwd: None,
|
||||
},
|
||||
);
|
||||
let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None);
|
||||
|
|
@ -5856,6 +5921,7 @@ reasoning_effort = "low"
|
|||
args: None,
|
||||
token_ttl_secs: Some(3600),
|
||||
timeout_secs: None,
|
||||
cwd: None,
|
||||
},
|
||||
);
|
||||
let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None);
|
||||
|
|
@ -6014,6 +6080,35 @@ reasoning_effort = "low"
|
|||
);
|
||||
}
|
||||
#[test]
|
||||
fn shell_environment_policy_typo_does_not_fail_config() {
|
||||
let cfg: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[shell_environment_policy]
|
||||
inhert = "core"
|
||||
exclude = 123
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Config::new_from_toml_cfg(&cfg).expect("a policy typo must not fail the config");
|
||||
}
|
||||
#[test]
|
||||
fn shell_environment_policy_known_keys_track_the_policy_struct() {
|
||||
let xai_grok_tools::util::ShellEnvironmentPolicy {
|
||||
inherit: _,
|
||||
ignore_default_excludes: _,
|
||||
exclude: _,
|
||||
set: _,
|
||||
include_only: _,
|
||||
} = xai_grok_tools::util::ShellEnvironmentPolicy::default();
|
||||
let ShellEnvironmentPolicyKnownKeys {
|
||||
inherit: _,
|
||||
ignore_default_excludes: _,
|
||||
exclude: _,
|
||||
set: _,
|
||||
include_only: _,
|
||||
} = ShellEnvironmentPolicyKnownKeys::default();
|
||||
}
|
||||
#[test]
|
||||
fn web_search_disable_api_key_auth_swaps_first_party_key_for_session() {
|
||||
let endpoints = EndpointsConfig::default();
|
||||
let mut models = IndexMap::new();
|
||||
|
|
@ -6088,6 +6183,7 @@ reasoning_effort = "low"
|
|||
args: Some(vec!["--scope".into(), "corp".into()]),
|
||||
token_ttl_secs: Some(3600),
|
||||
timeout_secs: Some(10),
|
||||
cwd: None,
|
||||
})
|
||||
);
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
|
|
@ -6179,6 +6275,7 @@ reasoning_effort = "low"
|
|||
args: None,
|
||||
token_ttl_secs: Some(3600),
|
||||
timeout_secs: None,
|
||||
cwd: None,
|
||||
},
|
||||
);
|
||||
model.auth_provider = Some(provider.clone());
|
||||
|
|
@ -6205,6 +6302,7 @@ reasoning_effort = "low"
|
|||
args: None,
|
||||
token_ttl_secs: Some(3600),
|
||||
timeout_secs: None,
|
||||
cwd: None,
|
||||
},
|
||||
);
|
||||
model.auth_provider = Some(provider.clone());
|
||||
|
|
@ -6249,6 +6347,7 @@ reasoning_effort = "low"
|
|||
args: None,
|
||||
token_ttl_secs: None,
|
||||
timeout_secs: None,
|
||||
cwd: None,
|
||||
},
|
||||
);
|
||||
let resolved = resolve_model_list(&cfg, Some(prefetched));
|
||||
|
|
@ -6302,6 +6401,8 @@ reasoning_effort = "low"
|
|||
api_backend: ApiBackend::default(),
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||||
auto_compact_threshold_percent: None,
|
||||
system_prompt_label: None,
|
||||
|
|
@ -9347,6 +9448,40 @@ reasoning_effort = "low"
|
|||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_image_edit_model_override_remote_settings_or_config() {
|
||||
unsafe { std::env::remove_var("GROK_IMAGE_EDIT_MODEL_OVERRIDE") };
|
||||
let with = |config: Option<&str>, gb: Option<&str>| Config {
|
||||
features: Features {
|
||||
image_edit_model_override: config.map(String::from),
|
||||
..Default::default()
|
||||
},
|
||||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||||
image_edit_model_override: gb.map(String::from),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(Config::default().resolve_image_edit_model_override(), None);
|
||||
assert_eq!(
|
||||
with(None, Some("grok-imagine-image")).resolve_image_edit_model_override(),
|
||||
Some("grok-imagine-image".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
with(Some("grok-imagine-image-pro"), Some("grok-imagine-image"))
|
||||
.resolve_image_edit_model_override(),
|
||||
Some("grok-imagine-image-pro".to_owned())
|
||||
);
|
||||
let gen_only = Config {
|
||||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||||
image_gen_model_override: Some("grok-imagine-image".to_owned()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(gen_only.resolve_image_edit_model_override(), None);
|
||||
}
|
||||
#[test]
|
||||
#[serial]
|
||||
fn imagine_tools_disabled_gates_image_edit() {
|
||||
unsafe { std::env::remove_var("GROK_IMAGE_EDIT") };
|
||||
let with_list = |tools: Vec<&str>| Config {
|
||||
|
|
@ -11668,6 +11803,8 @@ default = "grok-4.5"
|
|||
api_backend,
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: NonZeroU64::new(context_window).unwrap(),
|
||||
use_concise: false,
|
||||
agent_type: default_agent_type(),
|
||||
|
|
|
|||
|
|
@ -682,6 +682,12 @@ mod tests {
|
|||
extra_headers: [("x-team".to_owned(), "codegen".to_owned())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
query_params: [("api-version".to_owned(), "2026-07-22".to_owned())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
env_http_headers: [("x-tenant-token".to_owned(), "TENANT_TOKEN_VAR".to_owned())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
context_window: Some(200_000),
|
||||
auto_compact_threshold_percent: Some(80),
|
||||
system_prompt_label: Some("label".into()),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ pub struct ModelProviderConfig {
|
|||
pub api_key: Option<String>,
|
||||
pub api_backend: Option<ApiBackend>,
|
||||
pub extra_headers: IndexMap<String, String>,
|
||||
/// Query parameters folded into every request URL; inherited by models.
|
||||
pub query_params: IndexMap<String, String>,
|
||||
/// Header name to environment variable; inherited by models, resolved at
|
||||
/// client build.
|
||||
pub env_http_headers: IndexMap<String, String>,
|
||||
pub auth_provider: Option<String>,
|
||||
pub auth: Option<crate::auth::AuthProviderConfig>,
|
||||
pub context_window: Option<u64>,
|
||||
|
|
@ -175,6 +180,8 @@ impl ConfigModelOverride {
|
|||
api_key,
|
||||
api_backend,
|
||||
extra_headers,
|
||||
query_params,
|
||||
env_http_headers,
|
||||
auth_provider,
|
||||
auth,
|
||||
context_window,
|
||||
|
|
@ -186,9 +193,16 @@ impl ConfigModelOverride {
|
|||
merged.api_base_url = merged.api_base_url.or_else(|| api_base_url.clone());
|
||||
merged.api_backend = merged.api_backend.or_else(|| api_backend.clone());
|
||||
merged.context_window = merged.context_window.or(*context_window);
|
||||
// Inherited wholesale only when the model sets none of its own.
|
||||
if merged.extra_headers.is_empty() {
|
||||
merged.extra_headers = extra_headers.clone();
|
||||
}
|
||||
if merged.query_params.is_empty() {
|
||||
merged.query_params = query_params.clone();
|
||||
}
|
||||
if merged.env_http_headers.is_empty() {
|
||||
merged.env_http_headers = env_http_headers.clone();
|
||||
}
|
||||
let model_sets_own_api_key = self
|
||||
.api_key
|
||||
.as_deref()
|
||||
|
|
@ -914,4 +928,83 @@ mod tests {
|
|||
assert_eq!(provider.name.as_str(), "model_provider:gateway");
|
||||
assert!(!provider.is_fail_closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_inherits_provider_query_params_and_env_http_headers() {
|
||||
let toml_cfg: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[model_providers.gateway]
|
||||
base_url = "https://gateway.example/v1"
|
||||
api_key = "sk-provider"
|
||||
|
||||
[model_providers.gateway.query_params]
|
||||
api-version = "2026-07-22"
|
||||
|
||||
[model_providers.gateway.env_http_headers]
|
||||
X-Tenant-Token = "GATEWAY_TENANT_TOKEN"
|
||||
|
||||
[model.via-gateway]
|
||||
model = "m"
|
||||
model_provider = "gateway"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = Config::new_from_toml_cfg(&toml_cfg).expect("config should parse");
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
let model = resolved.get("via-gateway").expect("model should exist");
|
||||
assert_eq!(
|
||||
model
|
||||
.info
|
||||
.query_params
|
||||
.get("api-version")
|
||||
.map(String::as_str),
|
||||
Some("2026-07-22"),
|
||||
"the model inherits the provider's query params"
|
||||
);
|
||||
assert_eq!(
|
||||
model
|
||||
.info
|
||||
.env_http_headers
|
||||
.get("X-Tenant-Token")
|
||||
.map(String::as_str),
|
||||
Some("GATEWAY_TENANT_TOKEN"),
|
||||
"the model inherits the provider's env_http_headers mapping (unresolved names)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_query_params_shadow_provider_query_params() {
|
||||
let toml_cfg: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[model_providers.gateway]
|
||||
base_url = "https://gateway.example/v1"
|
||||
api_key = "sk-provider"
|
||||
|
||||
[model_providers.gateway.query_params]
|
||||
api-version = "provider"
|
||||
|
||||
[model.via-gateway]
|
||||
model = "m"
|
||||
model_provider = "gateway"
|
||||
|
||||
[model.via-gateway.query_params]
|
||||
api-version = "model"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = Config::new_from_toml_cfg(&toml_cfg).expect("config should parse");
|
||||
let resolved = resolve_model_list(&cfg, None);
|
||||
let model = resolved.get("via-gateway").expect("model should exist");
|
||||
assert_eq!(
|
||||
model
|
||||
.info
|
||||
.query_params
|
||||
.get("api-version")
|
||||
.map(String::as_str),
|
||||
Some("model"),
|
||||
"a model that sets its own query params inherits none of the provider's"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1834,26 +1834,30 @@ impl acp::Agent for MvpAgent {
|
|||
cwd.as_path(),
|
||||
remote_settings.as_ref(),
|
||||
);
|
||||
if let Some((parent_cmd_tx, session_cwd)) = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(&session_id)
|
||||
.map(|h| (h.cmd_tx.clone(), h.info.cwd.clone()))
|
||||
{
|
||||
let orphan_parent = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.map(|handle| (handle.cmd_tx.clone(), handle.info.cwd.clone()))
|
||||
};
|
||||
if let Some((parent_cmd_tx, session_cwd)) = orphan_parent {
|
||||
let session_dir = crate::session::persistence::session_dir(
|
||||
&SessionInfo {
|
||||
id: session_id.clone(),
|
||||
cwd: session_cwd,
|
||||
},
|
||||
);
|
||||
crate::agent::subagent::reconcile_orphaned_subagents(
|
||||
&unfinished_subagents,
|
||||
&self.subagent_coordinator.borrow(),
|
||||
&session_dir,
|
||||
session_id.0.as_ref(),
|
||||
&self.gateway,
|
||||
Some(&parent_cmd_tx),
|
||||
);
|
||||
crate::agent::subagent::reconcile_orphaned_subagents_with_backend(
|
||||
&unfinished_subagents,
|
||||
&xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
|
||||
self.subagent_event_tx.clone(),
|
||||
),
|
||||
&session_dir,
|
||||
session_id.0.as_ref(),
|
||||
&self.gateway,
|
||||
Some(&parent_cmd_tx),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let persisted_model = summary.current_model_id.clone();
|
||||
let models = self.models_manager.models();
|
||||
|
|
@ -2638,9 +2642,11 @@ impl acp::Agent for MvpAgent {
|
|||
tool_overrides: _,
|
||||
} = turn_ok;
|
||||
let subagent_refs = self
|
||||
.subagent_coordinator
|
||||
.borrow()
|
||||
.spawned_refs_for_prompt(&prompt_id);
|
||||
.spawned_subagent_refs_for_prompt(
|
||||
arguments.session_id.0.as_ref(),
|
||||
&prompt_id,
|
||||
)
|
||||
.await;
|
||||
let permission_events = self
|
||||
.collect_permission_events(&arguments.session_id);
|
||||
let turn_messages: Option<xai_chat_state::TurnCapture> = {
|
||||
|
|
@ -3091,9 +3097,11 @@ impl acp::Agent for MvpAgent {
|
|||
}
|
||||
Err(err) => {
|
||||
let subagent_refs = self
|
||||
.subagent_coordinator
|
||||
.borrow()
|
||||
.spawned_refs_for_prompt(&prompt_id);
|
||||
.spawned_subagent_refs_for_prompt(
|
||||
arguments.session_id.0.as_ref(),
|
||||
&prompt_id,
|
||||
)
|
||||
.await;
|
||||
let turn_messages: Option<xai_chat_state::TurnCapture> = {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if handle
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! Inherent [`MvpAgent`] helpers (MCP/clients/gateway, settings/models, session ops, spawn).
|
||||
//! Co-located child of `mvp_agent` (`use super::*`).
|
||||
use super::*;
|
||||
use xai_grok_tools::implementations::grok_build::task::backend::SubagentBackend;
|
||||
/// `preferred` model, else catalog `current`, else first with own credentials.
|
||||
fn byok_from_models(
|
||||
models: &indexmap::IndexMap<String, ModelEntry>,
|
||||
|
|
@ -411,9 +412,6 @@ impl MvpAgent {
|
|||
/// Must be called right after construction: entries registered on the
|
||||
/// constructor-created default instance are NOT migrated.
|
||||
pub fn set_activity(&mut self, activity: crate::agent::activity::AgentActivity) {
|
||||
self.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.set_running_gauge(activity.subagent_gauge());
|
||||
self.activity = activity;
|
||||
}
|
||||
/// Install the channel that fans new session cwds into the leader's
|
||||
|
|
@ -1339,6 +1337,7 @@ impl MvpAgent {
|
|||
image_gen_enabled: cfg.resolve_image_gen().value,
|
||||
image_edit_enabled: cfg.resolve_image_edit().value,
|
||||
model_override: cfg.resolve_image_gen_model_override(),
|
||||
edit_model_override: cfg.resolve_image_edit_model_override(),
|
||||
tier_restricted,
|
||||
}
|
||||
}
|
||||
|
|
@ -1534,8 +1533,6 @@ impl MvpAgent {
|
|||
}
|
||||
let (subagent_event_tx, subagent_event_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let activity = crate::agent::activity::AgentActivity::default();
|
||||
let mut subagent_coordinator = crate::agent::subagent::SubagentCoordinator::new();
|
||||
subagent_coordinator.set_running_gauge(activity.subagent_gauge());
|
||||
let instance = Self {
|
||||
sessions: RefCell::new(HashMap::new()),
|
||||
activity,
|
||||
|
|
@ -1604,7 +1601,9 @@ impl MvpAgent {
|
|||
model_unavailable_sessions: RefCell::new(std::collections::HashMap::new()),
|
||||
subagent_event_tx,
|
||||
subagent_event_rx: RefCell::new(Some(subagent_event_rx)),
|
||||
subagent_coordinator: RefCell::new(subagent_coordinator),
|
||||
subagent_presentation: RefCell::new(
|
||||
crate::agent::subagent::SubagentPresentation::new(),
|
||||
),
|
||||
monitor_event_buffer: xai_grok_tools::implementations::grok_build::task::types::MonitorEventBuffer::default(),
|
||||
bundle_sync_in_flight: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
post_unblock_jwt_retry_in_flight: Arc::new(
|
||||
|
|
@ -1895,46 +1894,74 @@ impl MvpAgent {
|
|||
/// Cancel a subagent by id, returning a typed outcome that backs the pager's
|
||||
/// `x.ai/subagent/cancel`. Active/pending → cancelled (a finish follows);
|
||||
/// already-finished → its terminal status; unknown id → `NotFound`.
|
||||
pub fn cancel_subagent(
|
||||
pub async fn cancel_subagent(
|
||||
&self,
|
||||
subagent_id: &str,
|
||||
) -> xai_grok_tools::implementations::grok_build::task::types::SubagentCancelOutcome {
|
||||
self.subagent_coordinator.borrow_mut().cancel_with_outcome(subagent_id)
|
||||
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
|
||||
self.subagent_event_tx.clone(),
|
||||
)
|
||||
.cancel(subagent_id)
|
||||
.await
|
||||
}
|
||||
/// List running subagent seeds for a given parent session.
|
||||
///
|
||||
/// Synchronously collects seeds from the coordinator, suitable for
|
||||
/// async resolution via `resolve_running_list()` after the borrow is
|
||||
/// dropped.
|
||||
pub(crate) fn list_running_subagents(
|
||||
pub(crate) async fn list_running_subagents(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Vec<crate::agent::subagent::RunningSubagentListSeed> {
|
||||
self.subagent_coordinator.borrow().list_running_for_parent(parent_session_id)
|
||||
) -> Vec<
|
||||
xai_grok_tools::implementations::grok_build::task::types::SubagentInspection,
|
||||
> {
|
||||
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
|
||||
self.subagent_event_tx.clone(),
|
||||
)
|
||||
.list_running(parent_session_id)
|
||||
.await
|
||||
}
|
||||
/// Return fork provenance metadata for a subagent.
|
||||
pub(crate) fn provenance_for_subagent(
|
||||
pub(crate) async fn inspect_subagent(
|
||||
&self,
|
||||
subagent_id: &str,
|
||||
) -> crate::agent::subagent::SubagentProvenance {
|
||||
self.subagent_coordinator.borrow().provenance_for(subagent_id)
|
||||
) -> Option<
|
||||
xai_grok_tools::implementations::grok_build::task::types::SubagentInspection,
|
||||
> {
|
||||
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
|
||||
self.subagent_event_tx.clone(),
|
||||
)
|
||||
.inspect(subagent_id)
|
||||
.await
|
||||
}
|
||||
/// Return `(parent_session_id, child_session_id)` for a subagent.
|
||||
pub(crate) fn session_ids_for_subagent(
|
||||
pub(crate) async fn query_subagent(
|
||||
&self,
|
||||
subagent_id: &str,
|
||||
) -> Option<(String, String)> {
|
||||
self.subagent_coordinator.borrow().session_ids_for(subagent_id)
|
||||
block: bool,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Option<
|
||||
xai_grok_tools::implementations::grok_build::task::types::SubagentSnapshot,
|
||||
> {
|
||||
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
|
||||
self.subagent_event_tx.clone(),
|
||||
)
|
||||
.query(subagent_id, block, timeout_ms)
|
||||
.await
|
||||
}
|
||||
/// Synchronous lookup of a single subagent by ID.
|
||||
///
|
||||
/// Returns `Option<SnapshotLookup>` which must be resolved
|
||||
/// asynchronously via `resolve_snapshot()` after the borrow is dropped.
|
||||
pub(crate) fn lookup_subagent(
|
||||
pub(super) async fn spawned_subagent_refs_for_prompt(
|
||||
&self,
|
||||
subagent_id: &str,
|
||||
) -> Option<crate::agent::subagent::SnapshotLookup> {
|
||||
self.subagent_coordinator.borrow().lookup(subagent_id)
|
||||
parent_session_id: &str,
|
||||
prompt_id: &str,
|
||||
) -> Vec<crate::upload::trace::SubagentSpawnedRef> {
|
||||
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
|
||||
self.subagent_event_tx.clone(),
|
||||
)
|
||||
.spawned_refs_for_prompt(parent_session_id, prompt_id)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|child| crate::upload::trace::SubagentSpawnedRef {
|
||||
subagent_id: child.subagent_id,
|
||||
child_session_id: child.child_session_id,
|
||||
subagent_type: child.subagent_type,
|
||||
description: child.description,
|
||||
persona: child.persona,
|
||||
resumed_from: child.resumed_from,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// List all background tasks for a session.
|
||||
/// Routes through the session's tool bridge to the TerminalBackend.
|
||||
|
|
@ -3153,19 +3180,19 @@ impl MvpAgent {
|
|||
})?;
|
||||
tool_ctx.subagent_event_tx = Some(self.subagent_event_tx.clone());
|
||||
tool_ctx.synthetic_trace_tx = self
|
||||
.subagent_coordinator
|
||||
.subagent_presentation
|
||||
.borrow()
|
||||
.synthetic_trace_tx
|
||||
.clone();
|
||||
if let Some(ref shared) = tool_ctx.synthetic_trace_tx_shared {
|
||||
*shared.lock().unwrap_or_else(|e| e.into_inner()) = self
|
||||
.subagent_coordinator
|
||||
.subagent_presentation
|
||||
.borrow()
|
||||
.synthetic_trace_tx
|
||||
.clone();
|
||||
}
|
||||
tool_ctx.is_turn_active = Some(
|
||||
self.subagent_coordinator.borrow().turn_active_flag(),
|
||||
self.subagent_presentation.borrow().turn_active_flag(),
|
||||
);
|
||||
tool_ctx.monitor_event_buffer = Some(self.monitor_event_buffer.clone());
|
||||
tool_ctx.subagent_depth = 0;
|
||||
|
|
|
|||
|
|
@ -500,6 +500,7 @@ struct SettingsUpdateNotification {
|
|||
privacy_banner_reshow_days: Option<u64>,
|
||||
session_picker_grouped: Option<bool>,
|
||||
tips: Option<Vec<String>>,
|
||||
slash_command_tags: Option<std::collections::BTreeMap<String, String>>,
|
||||
announcements: Option<Vec<xai_grok_announcements::RemoteAnnouncement>>,
|
||||
gate_message: Option<String>,
|
||||
gate_url: Option<String>,
|
||||
|
|
@ -797,9 +798,8 @@ pub struct MvpAgent {
|
|||
>,
|
||||
>,
|
||||
>,
|
||||
/// Active subagent tracking — owns all subagent lifecycle state.
|
||||
/// LEADER-SAFE(per-session): keyed by subagent_id, no cross-session iteration.
|
||||
subagent_coordinator: RefCell<crate::agent::subagent::SubagentCoordinator>,
|
||||
/// Shell-only presentation state; lifecycle lives in the channel actor.
|
||||
subagent_presentation: RefCell<crate::agent::subagent::SubagentPresentation>,
|
||||
/// Shared buffer for mid-turn monitor event notifications.
|
||||
/// Pushed by the `InjectNotification` handler when a turn is active and the
|
||||
/// notification has `Next` priority. Drained by the session turn loop
|
||||
|
|
@ -1687,6 +1687,7 @@ impl MvpAgent {
|
|||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
owner_session_id: None,
|
||||
description: None,
|
||||
};
|
||||
let notification = crate::extensions::notification::SessionNotification {
|
||||
session_id: session_id.clone(),
|
||||
|
|
@ -2092,6 +2093,7 @@ impl MvpAgent {
|
|||
.and_then(|s| s.privacy_banner_reshow_days),
|
||||
session_picker_grouped: rs.and_then(|s| s.session_picker_grouped),
|
||||
tips: rs.and_then(|s| s.tips.clone()),
|
||||
slash_command_tags: rs.and_then(|s| s.slash_command_tags.clone()),
|
||||
announcements: rs.and_then(|s| s.announcements.clone()),
|
||||
gate_message: rs.and_then(|s| s.gate_message.clone()),
|
||||
gate_url: rs.and_then(|s| s.gate_url.clone()),
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ impl MvpAgent {
|
|||
if let Some(ops) = self.workspace_ops.borrow().as_ref() {
|
||||
ops.end_local_session(id.0.as_ref());
|
||||
}
|
||||
self.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.discard_pending_completions_for(id.0.as_ref());
|
||||
let _ = self
|
||||
.subagent_event_tx
|
||||
.send(xai_grok_tools::implementations::grok_build::task::types::SubagentEvent::DiscardSessionCompletions {
|
||||
parent_session_id: id.0.to_string(),
|
||||
});
|
||||
}
|
||||
/// Get-or-create the per-session dispatch lock (see
|
||||
/// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`.
|
||||
|
|
@ -406,10 +408,14 @@ impl MvpAgent {
|
|||
.unwrap_or(true)
|
||||
}
|
||||
/// Entry counts for every collection [`Self::remove_session`] drains,
|
||||
/// plus the workspace binding and subagent maps.
|
||||
pub(crate) fn registry_snapshot(&self) -> RegistrySnapshot {
|
||||
let (subagent_pending, subagent_active, subagent_completed) =
|
||||
self.subagent_coordinator.borrow().registry_snapshot();
|
||||
/// plus workspace bindings and shared coordinator state.
|
||||
pub(crate) async fn registry_snapshot(&self) -> RegistrySnapshot {
|
||||
let subagents =
|
||||
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
|
||||
self.subagent_event_tx.clone(),
|
||||
)
|
||||
.registry_counts()
|
||||
.await;
|
||||
RegistrySnapshot {
|
||||
sessions: self.sessions.borrow().len(),
|
||||
session_threads: self.session_threads.borrow().len(),
|
||||
|
|
@ -420,9 +426,9 @@ impl MvpAgent {
|
|||
session_live_state: self.session_live_state.borrow().len(),
|
||||
session_index_claims: self.session_index_claims.borrow().len(),
|
||||
require_gateway_sessions: self.require_gateway_sessions.borrow().len(),
|
||||
subagent_pending,
|
||||
subagent_active,
|
||||
subagent_completed,
|
||||
subagent_pending: subagents.pending,
|
||||
subagent_active: subagents.active,
|
||||
subagent_completed: subagents.completed,
|
||||
workspace_bindings: self
|
||||
.workspace_ops
|
||||
.borrow()
|
||||
|
|
|
|||
|
|
@ -1,348 +1,204 @@
|
|||
//! Subagent coordinator drain task and spawn-context construction for [`MvpAgent`].
|
||||
//! Co-located child of `mvp_agent` (`use super::*`); tested by `tests/subagent_spawn_context_tests.rs`.
|
||||
//! Shell runner adapter and spawn-context construction for [`MvpAgent`].
|
||||
//! The shared coordinator actor lives in `xai-grok-tools`; this module plugs
|
||||
//! its `!Send` local-session runner into `spawn_local`.
|
||||
use super::*;
|
||||
use crate::session::repo_changes::UploadMethod;
|
||||
struct ShellChildRunner {
|
||||
agent_ref: LocalRef<MvpAgent>,
|
||||
}
|
||||
impl xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunner
|
||||
for ShellChildRunner
|
||||
{
|
||||
type Control = crate::agent::subagent::ShellChildRuntime;
|
||||
type CompletionData = crate::agent::subagent::ShellCompletionData;
|
||||
type RunFuture = xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture<
|
||||
xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunOutput<
|
||||
Self::CompletionData,
|
||||
>,
|
||||
>;
|
||||
type ValidateFuture =
|
||||
xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture<
|
||||
xai_grok_tools::implementations::grok_build::task::types::SubagentValidateTypeOutcome,
|
||||
>;
|
||||
type DescribeFuture =
|
||||
xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture<
|
||||
xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome,
|
||||
>;
|
||||
fn run(
|
||||
&self,
|
||||
run: xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunRequest<
|
||||
Self::Control,
|
||||
>,
|
||||
) -> Self::RunFuture {
|
||||
let agent_ref = self.agent_ref.clone();
|
||||
Box::pin(async move {
|
||||
let xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunRequest {
|
||||
request,
|
||||
cancellation,
|
||||
reporter,
|
||||
} = run;
|
||||
let this = agent_ref.get();
|
||||
let parent_sid = request.parent_session_id.clone();
|
||||
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 or evicted parent session"
|
||||
);
|
||||
return xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunOutput {
|
||||
result: xai_grok_tools::implementations::grok_build::task::types::SubagentResult {
|
||||
success: false,
|
||||
error: Some(
|
||||
"Parent session not found (evicted or torn down); cannot spawn subagent."
|
||||
.to_owned(),
|
||||
),
|
||||
subagent_id: request.id.clone(),
|
||||
child_session_id: request.id,
|
||||
..Default::default()
|
||||
},
|
||||
completion_data: Default::default(),
|
||||
snapshot_ref: None,
|
||||
};
|
||||
};
|
||||
let parent_handle = {
|
||||
let parent_sid = acp::SessionId::new(parent_sid);
|
||||
this.sessions.borrow().get(&parent_sid).cloned()
|
||||
};
|
||||
if let Some(handle) = parent_handle {
|
||||
ctx.parent_mcp_pool = handle.snapshot_mcp_pool().await;
|
||||
ctx.client_hooks = handle.snapshot_client_hooks().await;
|
||||
let definitions = handle.snapshot_tool_definitions().await;
|
||||
ctx.parent_tool_definitions = (!definitions.is_empty()).then_some(definitions);
|
||||
}
|
||||
crate::agent::subagent::run_shell_child(
|
||||
request,
|
||||
ctx,
|
||||
cancellation,
|
||||
reporter,
|
||||
&this.gateway,
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
fn validate_type(
|
||||
&self,
|
||||
subagent_type: String,
|
||||
parent_session_id: String,
|
||||
) -> Self::ValidateFuture {
|
||||
let agent_ref = self.agent_ref.clone();
|
||||
Box::pin(async move {
|
||||
let this = agent_ref.get();
|
||||
let ctx = this.build_subagent_validation_context(&parent_session_id);
|
||||
crate::agent::subagent::validate_subagent_type(&subagent_type, &ctx)
|
||||
})
|
||||
}
|
||||
fn describe_type(
|
||||
&self,
|
||||
subagent_type: String,
|
||||
harness_agent_type: Option<String>,
|
||||
parent_session_id: String,
|
||||
) -> Self::DescribeFuture {
|
||||
let agent_ref = self.agent_ref.clone();
|
||||
Box::pin(async move {
|
||||
let this = agent_ref.get();
|
||||
match this.try_build_subagent_spawn_context(&parent_session_id) {
|
||||
Some(ctx) => crate::agent::subagent::describe_subagent_type(
|
||||
&subagent_type,
|
||||
harness_agent_type.as_deref(),
|
||||
&ctx,
|
||||
),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
parent_session_id,
|
||||
subagent_type,
|
||||
"DescribeType for unknown/evicted parent session, replying Unavailable",
|
||||
);
|
||||
xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome::Unavailable
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn on_completed(
|
||||
&self,
|
||||
completion: xai_grok_tools::implementations::grok_build::task::coordinator::ChildCompletion<
|
||||
Self::CompletionData,
|
||||
>,
|
||||
) {
|
||||
let gateway = self.agent_ref.get().gateway.clone();
|
||||
crate::agent::subagent::present_child_completion(completion, &gateway);
|
||||
}
|
||||
fn running_count_changed(&self, running: usize) {
|
||||
self.agent_ref
|
||||
.get()
|
||||
.activity
|
||||
.subagent_gauge()
|
||||
.store(running, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fn persisted_output_ref(&self, completion_data: &Self::CompletionData) -> Option<String> {
|
||||
completion_data
|
||||
.persisted_output_dir()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
}
|
||||
fn load_persisted_output(&self, reference: &str) -> Option<std::sync::Arc<str>> {
|
||||
crate::agent::subagent::read_subagent_output(std::path::Path::new(reference))
|
||||
.map(std::sync::Arc::from)
|
||||
}
|
||||
}
|
||||
impl MvpAgent {
|
||||
/// Start the subagent coordinator drain task.
|
||||
/// Start the shared subagent coordinator actor.
|
||||
///
|
||||
/// Takes the `subagent_event_rx` receiver (once) and spawns a `spawn_local` task
|
||||
/// that receives `SubagentRequest`s and delegates each to
|
||||
/// `handle_subagent_request()` on its own `spawn_local` task.
|
||||
/// Takes `subagent_event_rx` once and `spawn_local`s one
|
||||
/// [`SubagentCoordinator`](xai_grok_tools::implementations::grok_build::task::coordinator::SubagentCoordinator)
|
||||
/// that drains `ChannelBackend` events (`Spawn` / await / cancel / inspect)
|
||||
/// through [`ShellChildRunner`]. The actor owns pending/active/completed
|
||||
/// state, waiters, deadlines, and completion disposition; the runner only
|
||||
/// builds shell child sessions via `run_shell_child`.
|
||||
///
|
||||
/// Uses `LocalRef` to reference `self` from
|
||||
/// `spawn_local` closures. Idempotent: subsequent calls are no-ops.
|
||||
/// Uses `LocalRef` so the `!Send` runner can touch `self` from the
|
||||
/// `LocalSet`. Idempotent: subsequent calls are no-ops.
|
||||
pub(super) fn start_subagent_coordinator(&self) {
|
||||
let Some(mut rx) = self.subagent_event_rx.borrow_mut().take() else {
|
||||
let Some(rx) = self.subagent_event_rx.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
let agent_ref = LocalRef::new(self);
|
||||
use crate::agent::subagent::{BlockWaitSlot, is_running, resolve_snapshot};
|
||||
use xai_grok_tools::implementations::grok_build::task::types::{
|
||||
SubagentCancelOutcome, SubagentCancelTarget, SubagentEvent,
|
||||
let runner = ShellChildRunner {
|
||||
agent_ref: agent_ref.clone(),
|
||||
};
|
||||
let config =
|
||||
xai_grok_tools::implementations::grok_build::task::coordinator::CoordinatorConfig {
|
||||
foreground_budget:
|
||||
xai_grok_tools::implementations::grok_build::task::backend::env_duration_or(
|
||||
"GROK_SUBAGENT_AWAIT_BUDGET_MS",
|
||||
std::time::Duration::from_secs(600),
|
||||
),
|
||||
buffer_completions: true,
|
||||
buffered_completion_output_cap: None,
|
||||
};
|
||||
tokio::task::spawn_local(
|
||||
xai_grok_tools::implementations::grok_build::task::coordinator::SubagentCoordinator::new(
|
||||
rx,
|
||||
runner,
|
||||
config,
|
||||
)
|
||||
.run(),
|
||||
);
|
||||
let (trace_tx, mut trace_rx) = tokio::sync::mpsc::unbounded_channel::<
|
||||
crate::upload::turn::SyntheticTurnTraceRequest,
|
||||
>();
|
||||
self.subagent_presentation.borrow_mut().synthetic_trace_tx = Some(trace_tx);
|
||||
tokio::task::spawn_local({
|
||||
let agent_ref = agent_ref.clone();
|
||||
async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
SubagentEvent::Spawn(boxed) => {
|
||||
let mut request = *boxed;
|
||||
{
|
||||
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 child_sess = request.parent_session_id.clone();
|
||||
let reparent = {
|
||||
let coord = this.subagent_coordinator.borrow();
|
||||
coord.parent_of_child_session(&child_sess).map(|root| {
|
||||
(root, coord.loop_task_id_of_child_session(&child_sess))
|
||||
})
|
||||
};
|
||||
if let Some((root, inherited_loop)) = reparent {
|
||||
tracing::info!(
|
||||
child_session_id = %child_sess,
|
||||
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;
|
||||
if request.runtime_overrides.loop_task_id.is_none() {
|
||||
request.runtime_overrides.loop_task_id = inherited_loop;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(task_id) =
|
||||
request.runtime_overrides.loop_task_id.clone()
|
||||
{
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.record_loop_owner(&request.id, &task_id);
|
||||
}
|
||||
}
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
let this = agent_ref.get();
|
||||
let parent_sid = request.parent_session_id.clone();
|
||||
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"
|
||||
);
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.remove_loop_owner(&request.id);
|
||||
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()
|
||||
};
|
||||
if let Some(handle) = parent_handle {
|
||||
ctx.parent_mcp_pool = handle.snapshot_mcp_pool().await;
|
||||
ctx.client_hooks = handle.snapshot_client_hooks().await;
|
||||
let parent_tools = handle.snapshot_tool_definitions().await;
|
||||
ctx.parent_tool_snapshot =
|
||||
(!parent_tools.is_empty()).then_some(parent_tools);
|
||||
}
|
||||
crate::agent::subagent::handle_subagent_request(
|
||||
request,
|
||||
ctx,
|
||||
&this.subagent_coordinator,
|
||||
&this.gateway,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
while let Some(request) = trace_rx.recv().await {
|
||||
tokio::task::spawn_local({
|
||||
let agent_ref = agent_ref.clone();
|
||||
async move {
|
||||
handle_synthetic_turn_trace(agent_ref, request).await;
|
||||
}
|
||||
SubagentEvent::Query(query) => {
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
let subagent_id = query.subagent_id;
|
||||
let block = query.block;
|
||||
let timeout_ms = query.timeout_ms;
|
||||
let slot: BlockWaitSlot = std::rc::Rc::new(
|
||||
std::cell::RefCell::new(Some(query.respond_to)),
|
||||
);
|
||||
let send_via_slot =
|
||||
|slot: &BlockWaitSlot, snap| match slot.borrow_mut().take() {
|
||||
Some(tx) => tx.send(snap).is_ok(),
|
||||
None => false,
|
||||
};
|
||||
let lookup = {
|
||||
let this = agent_ref.get();
|
||||
let result =
|
||||
this.subagent_coordinator.borrow().lookup(&subagent_id);
|
||||
if block && result.is_some() {
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.register_block_wait(&subagent_id, slot.clone());
|
||||
}
|
||||
result
|
||||
};
|
||||
let snapshot = resolve_snapshot(lookup).await;
|
||||
let should_block =
|
||||
block && snapshot.as_ref().is_some_and(is_running);
|
||||
if should_block {
|
||||
let timeout_ms = timeout_ms.unwrap_or(30_000);
|
||||
let deadline = tokio::time::Instant::now()
|
||||
+ tokio::time::Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200))
|
||||
.await;
|
||||
let receiver_gone =
|
||||
slot.borrow().as_ref().is_none_or(|tx| tx.is_closed());
|
||||
if receiver_gone {
|
||||
let this = agent_ref.get();
|
||||
let mut coord = this.subagent_coordinator.borrow_mut();
|
||||
coord.clear_block_waited(&subagent_id);
|
||||
coord.unregister_block_wait(&subagent_id, &slot);
|
||||
return;
|
||||
}
|
||||
let lookup = {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator.borrow().lookup(&subagent_id)
|
||||
};
|
||||
let snap = resolve_snapshot(lookup).await;
|
||||
let still_running = snap.as_ref().is_some_and(is_running);
|
||||
if !still_running || tokio::time::Instant::now() >= deadline
|
||||
{
|
||||
{
|
||||
let this = agent_ref.get();
|
||||
let mut coord =
|
||||
this.subagent_coordinator.borrow_mut();
|
||||
if still_running {
|
||||
coord.clear_block_waited(&subagent_id);
|
||||
}
|
||||
coord.unregister_block_wait(&subagent_id, &slot);
|
||||
}
|
||||
if !send_via_slot(&slot, snap) && !still_running {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.clear_block_waited(&subagent_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let delivered = send_via_slot(&slot, snapshot);
|
||||
if block {
|
||||
let this = agent_ref.get();
|
||||
let mut coord = this.subagent_coordinator.borrow_mut();
|
||||
coord.unregister_block_wait(&subagent_id, &slot);
|
||||
if !delivered {
|
||||
coord.clear_block_waited(&subagent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
SubagentEvent::Cancel(request) => match request.target {
|
||||
SubagentCancelTarget::WorkflowRunId(run_id) => {
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
let notify = {
|
||||
let this = agent_ref.get();
|
||||
let mut coord = this.subagent_coordinator.borrow_mut();
|
||||
coord.cancel_workflow_children(&run_id);
|
||||
coord.completion_notify()
|
||||
};
|
||||
loop {
|
||||
let notified = notify.notified();
|
||||
let outstanding = {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator
|
||||
.borrow()
|
||||
.outstanding_for_workflow(&run_id)
|
||||
};
|
||||
if outstanding == 0 {
|
||||
let _ = request
|
||||
.respond_to
|
||||
.send(SubagentCancelOutcome::Cancelled);
|
||||
break;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
target => {
|
||||
let this = agent_ref.get();
|
||||
let outcome = {
|
||||
let mut coord = this.subagent_coordinator.borrow_mut();
|
||||
match target {
|
||||
SubagentCancelTarget::SubagentId(ref subagent_id) => {
|
||||
coord.mark_explicitly_killed(subagent_id);
|
||||
coord.cancel_with_outcome(subagent_id)
|
||||
}
|
||||
SubagentCancelTarget::ParentPromptId(
|
||||
ref parent_prompt_id,
|
||||
) => {
|
||||
coord.cancel_by_parent_prompt_id(parent_prompt_id);
|
||||
SubagentCancelOutcome::Cancelled
|
||||
}
|
||||
SubagentCancelTarget::WorkflowRunId(_) => {
|
||||
unreachable!("handled above")
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = request.respond_to.send(outcome);
|
||||
}
|
||||
},
|
||||
SubagentEvent::ListActive(request) => {
|
||||
let this = agent_ref.get();
|
||||
let summaries = this
|
||||
.subagent_coordinator
|
||||
.borrow()
|
||||
.active_summaries_for(&request.parent_session_id);
|
||||
let _ = request.respond_to.send(summaries);
|
||||
}
|
||||
SubagentEvent::Completions(request) => {
|
||||
let this = agent_ref.get();
|
||||
let mut completions = this
|
||||
.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.drain_pending_completions_for(&request.session_id);
|
||||
completions.retain(|c| !request.suppress_ids.contains(&c.subagent_id));
|
||||
let _ = request.respond_to.send(completions);
|
||||
}
|
||||
SubagentEvent::Outstanding(request) => {
|
||||
let this = agent_ref.get();
|
||||
let reply = this
|
||||
.subagent_coordinator
|
||||
.borrow()
|
||||
.outstanding_reply_for_prompt(&request.prompt_id);
|
||||
let _ = request.respond_to.send(reply);
|
||||
}
|
||||
SubagentEvent::ClearUsageNotApplied(request) => {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.clear_subagent_usage_not_applied(&request.prompt_id);
|
||||
}
|
||||
SubagentEvent::MarkUsageNotApplied(request) => {
|
||||
let this = agent_ref.get();
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.mark_subagent_usage_not_applied(&request.prompt_id);
|
||||
let _ = request.respond_to.send(());
|
||||
}
|
||||
SubagentEvent::ValidateType(request) => {
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
let this = agent_ref.get();
|
||||
let ctx = this
|
||||
.build_subagent_validation_context(&request.parent_session_id);
|
||||
let outcome = crate::agent::subagent::validate_subagent_type(
|
||||
&request.subagent_type,
|
||||
&ctx,
|
||||
);
|
||||
let _ = request.respond_to.send(outcome);
|
||||
});
|
||||
}
|
||||
SubagentEvent::DescribeType(request) => {
|
||||
let agent_ref = agent_ref.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
use xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome;
|
||||
let this = agent_ref.get();
|
||||
let outcome = match this
|
||||
.try_build_subagent_spawn_context(&request.parent_session_id)
|
||||
{
|
||||
Some(ctx) => crate::agent::subagent::describe_subagent_type(
|
||||
&request.subagent_type,
|
||||
request.harness_agent_type.as_deref(),
|
||||
&ctx,
|
||||
),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
parent_session_id = %request.parent_session_id,
|
||||
subagent_type = %request.subagent_type,
|
||||
"DescribeType for unknown/evicted parent session, replying Unavailable",
|
||||
);
|
||||
SubagentDescribeOutcome::Unavailable
|
||||
}
|
||||
};
|
||||
let _ = request.respond_to.send(outcome);
|
||||
});
|
||||
}
|
||||
SubagentEvent::LoopUnitActive(request) => {
|
||||
let this = agent_ref.get();
|
||||
let active = this
|
||||
.subagent_coordinator
|
||||
.borrow()
|
||||
.loop_unit_active(&request.task_id);
|
||||
let _ = request.respond_to.send(active);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
{
|
||||
let (trace_tx, mut trace_rx) = tokio::sync::mpsc::unbounded_channel::<
|
||||
crate::upload::turn::SyntheticTurnTraceRequest,
|
||||
>();
|
||||
self.subagent_coordinator.borrow_mut().synthetic_trace_tx = Some(trace_tx);
|
||||
tokio::task::spawn_local({
|
||||
let agent_ref = agent_ref.clone();
|
||||
async move {
|
||||
while let Some(request) = trace_rx.recv().await {
|
||||
tokio::task::spawn_local({
|
||||
let agent_ref = agent_ref.clone();
|
||||
async move {
|
||||
handle_synthetic_turn_trace(agent_ref, request).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Lightweight context for the `SubagentEvent::ValidateType` drain arm;
|
||||
/// tolerates evicted parent sessions (returns built-in defaults + warns).
|
||||
|
|
@ -509,7 +365,6 @@ impl MvpAgent {
|
|||
};
|
||||
let (gcs_upload_method, gcs_bucket_url) = match self.trace_upload_config_snapshot() {
|
||||
Some(method) => {
|
||||
use crate::session::repo_changes::UploadMethod;
|
||||
let bucket = match &method {
|
||||
UploadMethod::Direct { .. } => self
|
||||
.cfg
|
||||
|
|
@ -552,7 +407,6 @@ impl MvpAgent {
|
|||
};
|
||||
Some(crate::agent::subagent::SubagentSpawnContext {
|
||||
lsp: parent_lsp,
|
||||
gateway: self.gateway.clone(),
|
||||
client_hooks: Default::default(),
|
||||
sampling_config: self.sampling_config.borrow().clone(),
|
||||
managed_mcp_proxy_base_url: parent_managed_mcp_proxy_base_url
|
||||
|
|
@ -565,7 +419,6 @@ impl MvpAgent {
|
|||
.cloned()
|
||||
.unwrap_or_else(|| acp::AuthMethodId::new("default")),
|
||||
model_id: parent_model_id,
|
||||
storage_mode: self.storage_mode,
|
||||
auth: self.current_or_buffered_auth(),
|
||||
parent_cwd: parent_cwd.clone(),
|
||||
parent_session_id: parent_session_id.to_string(),
|
||||
|
|
@ -632,7 +485,6 @@ impl MvpAgent {
|
|||
agent_config: Some(self.cfg.borrow().clone()),
|
||||
gcs_upload_method,
|
||||
hook_registry: parent_hook_registry,
|
||||
hook_workspace_root: String::new(),
|
||||
permission_handle: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
|
|
@ -664,7 +516,7 @@ impl MvpAgent {
|
|||
},
|
||||
managed_mcp_state: self.managed_mcp_cache.clone(),
|
||||
parent_mcp_pool: None,
|
||||
parent_tool_snapshot: None,
|
||||
parent_tool_definitions: None,
|
||||
parent_skills: None,
|
||||
parent_skills_config: self.cfg.borrow().skills.clone(),
|
||||
parent_compat: self.cfg.borrow().compat_resolved,
|
||||
|
|
@ -700,15 +552,6 @@ impl MvpAgent {
|
|||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false))
|
||||
})
|
||||
},
|
||||
parent_blocking_wait_depth: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.tool_context.blocking_wait_depth.clone())
|
||||
.unwrap_or_else(|| {
|
||||
std::sync::Arc::new(crate::tools::tool_context::BlockingWaitState::new())
|
||||
})
|
||||
},
|
||||
parent_terminal_backend: parent_terminal_backend.clone(),
|
||||
parent_notification_handle: parent_notification_handle.clone(),
|
||||
parent_scheduler_handle: parent_scheduler_handle.clone(),
|
||||
|
|
|
|||
|
|
@ -1074,6 +1074,7 @@ async fn file_toolset_override_e2e_to_finalized_toolset() {
|
|||
session_env: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||
notification_handle: ToolNotificationHandle::noop(),
|
||||
owner_session_id: None,
|
||||
subagent: None,
|
||||
parent_scheduler_handle: None,
|
||||
skills: vec![],
|
||||
state_path: tmp.path().join("state.json"),
|
||||
|
|
@ -2131,6 +2132,8 @@ fn find_model_by_id_prefers_key_then_falls_back_to_slug() {
|
|||
api_backend: crate::sampling::ApiBackend::default(),
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
query_params: IndexMap::new(),
|
||||
env_http_headers: IndexMap::new(),
|
||||
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
|
||||
auto_compact_threshold_percent: None,
|
||||
system_prompt_label: None,
|
||||
|
|
@ -2610,6 +2613,31 @@ async fn prepare_video_gen_config_sends_client_identifier_header() {
|
|||
applies the coding ZDR opt-out to Build traffic"
|
||||
);
|
||||
}
|
||||
/// Regression: `x.ai/auth/info` must return profile fields even when the
|
||||
/// access token is expired — profile data does not expire with the token,
|
||||
/// and hiding it made the desktop render "Signed in" with no identity.
|
||||
#[tokio::test]
|
||||
async fn auth_info_returns_profile_when_token_expired() {
|
||||
let agent = build_agent_with_auth(crate::auth::GrokAuth {
|
||||
email: Some("user@example.com".into()),
|
||||
first_name: Some("Test".into()),
|
||||
refresh_token: Some("rt".into()),
|
||||
expires_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)),
|
||||
..crate::auth::GrokAuth::test_default()
|
||||
});
|
||||
let resp = crate::extensions::auth::handle(
|
||||
&agent,
|
||||
&acp::ExtRequest::new(
|
||||
"x.ai/auth/info",
|
||||
std::sync::Arc::from(serde_json::value::to_raw_value(&serde_json::json!({})).unwrap()),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("auth/info must succeed with an expired token");
|
||||
let info: serde_json::Value = serde_json::from_str(resp.0.get()).unwrap();
|
||||
assert_eq!(info["email"], "user@example.com");
|
||||
assert_eq!(info["firstName"], "Test");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn data_collection_enabled_for_normal_user() {
|
||||
let agent = build_agent_with_auth(crate::auth::GrokAuth::test_default());
|
||||
|
|
@ -4775,6 +4803,11 @@ mod soft_default_settings_emit {
|
|||
let cfg = AgentConfig {
|
||||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||||
permission_mode: Some("always-approve".into()),
|
||||
slash_command_tags: Some(
|
||||
[("workflows".to_string(), "new".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
|
|
@ -4795,6 +4828,14 @@ mod soft_default_settings_emit {
|
|||
Some("always-approve"),
|
||||
"post-auth emit must carry remote permission_mode for first session"
|
||||
);
|
||||
assert_eq!(
|
||||
params
|
||||
.get("slash_command_tags")
|
||||
.and_then(|v| v.get("workflows"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("new"),
|
||||
"post-auth emit must carry remote slash_command_tags"
|
||||
);
|
||||
let _ = args.response_tx.send(Ok(()));
|
||||
})
|
||||
.await;
|
||||
|
|
|
|||
|
|
@ -1,578 +0,0 @@
|
|||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
#![allow(unused_imports)]
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::{Notify, mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
||||
use crate::session::{
|
||||
self, SessionCommand, SessionHandle, SessionThread,
|
||||
commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
|
||||
fs_watch::FsWatchCapabilities, info::Info as SessionInfo,
|
||||
};
|
||||
use crate::terminal::AsyncTerminalRunner;
|
||||
use crate::tools::ToolContext;
|
||||
use crate::upload::trace::{
|
||||
GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata,
|
||||
local_sandbox_telemetry, upload_metadata, upload_session_state,
|
||||
upload_subagent_metadata, upload_turn_result,
|
||||
};
|
||||
use crate::upload::turn::{PromptTraceContext, complete_prompt_trace};
|
||||
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
use xai_grok_tools::implementations::grok_build::task::types::*;
|
||||
use xai_grok_workspace::file_system::AsyncFileSystem;
|
||||
use xai_hunk_tracker::HunkTrackerHandle;
|
||||
use super::*;
|
||||
impl SubagentCoordinator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: HashMap::new(),
|
||||
active: HashMap::new(),
|
||||
completed: HashMap::new(),
|
||||
completion_notify: Arc::new(Notify::new()),
|
||||
pending_completions: Vec::new(),
|
||||
is_turn_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
synthetic_trace_tx: None,
|
||||
running_gauge: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
block_wait_slots: HashMap::new(),
|
||||
subagent_usage_not_applied_prompts: std::collections::HashSet::new(),
|
||||
loop_owned: HashMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn mark_subagent_usage_not_applied(&mut self, prompt_id: &str) {
|
||||
self.subagent_usage_not_applied_prompts.insert(prompt_id.to_string());
|
||||
}
|
||||
pub fn subagent_usage_not_applied(&self, prompt_id: &str) -> bool {
|
||||
self.subagent_usage_not_applied_prompts.contains(prompt_id)
|
||||
}
|
||||
pub fn clear_subagent_usage_not_applied(&mut self, prompt_id: &str) {
|
||||
self.subagent_usage_not_applied_prompts.remove(prompt_id);
|
||||
}
|
||||
pub fn parent_prompt_id_for(&self, subagent_id: &str) -> Option<String> {
|
||||
self.active
|
||||
.get(subagent_id)
|
||||
.and_then(|t| t.parent_prompt_id.clone())
|
||||
.or_else(|| {
|
||||
self.pending.get(subagent_id).and_then(|p| p.parent_prompt_id.clone())
|
||||
})
|
||||
}
|
||||
/// Rebind the running-subagent gauge, copying the current count so a
|
||||
/// late rebind cannot under-report.
|
||||
pub fn set_running_gauge(&mut self, gauge: Arc<std::sync::atomic::AtomicUsize>) {
|
||||
gauge
|
||||
.store(
|
||||
self.pending.len() + self.active.len(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
self.running_gauge = gauge;
|
||||
}
|
||||
/// Recompute the gauge from `pending` + `active` after every mutation of
|
||||
/// either map — recomputing (rather than incrementing) prevents drift.
|
||||
fn sync_running_gauge(&self) {
|
||||
self.running_gauge
|
||||
.store(
|
||||
self.pending.len() + self.active.len(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
pub fn completion_notify(&self) -> Arc<Notify> {
|
||||
Arc::clone(&self.completion_notify)
|
||||
}
|
||||
/// Returns a shared handle to the turn-active flag.
|
||||
pub fn turn_active_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
|
||||
Arc::clone(&self.is_turn_active)
|
||||
}
|
||||
/// Whether the model's turn is currently active.
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "used from tests only; remove expect when wired in production"
|
||||
)
|
||||
)]
|
||||
pub fn is_turn_active(&self) -> bool {
|
||||
self.is_turn_active.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// Pending + active turn-blocking subagent IDs for `prompt_id`.
|
||||
/// Background children are excluded: they outlive the turn by design, so
|
||||
/// the freeze drain must not wait on them (their spend reaches the session
|
||||
/// ledger when they finish; the prompt report flags them via
|
||||
/// `background_live`).
|
||||
pub fn outstanding_for_prompt(&self, prompt_id: &str) -> Vec<String> {
|
||||
let mut ids: Vec<String> = self
|
||||
.pending
|
||||
.values()
|
||||
.filter(|p| {
|
||||
p.parent_prompt_id.as_deref() == Some(prompt_id) && !p.run_in_background
|
||||
})
|
||||
.map(|p| p.subagent_id.clone())
|
||||
.chain(
|
||||
self
|
||||
.active
|
||||
.values()
|
||||
.filter(|t| {
|
||||
t.parent_prompt_id.as_deref() == Some(prompt_id)
|
||||
&& !t.run_in_background
|
||||
})
|
||||
.map(|t| t.subagent_id.clone()),
|
||||
)
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
/// True while any background child of `prompt_id` is pending or active.
|
||||
/// Their spend is missing from the prompt report (it lands on the session
|
||||
/// ledger at completion), so the report is incomplete — without waiting.
|
||||
pub fn background_live_for_prompt(&self, prompt_id: &str) -> bool {
|
||||
self
|
||||
.pending
|
||||
.values()
|
||||
.any(|p| {
|
||||
p.parent_prompt_id.as_deref() == Some(prompt_id) && p.run_in_background
|
||||
})
|
||||
|| self
|
||||
.active
|
||||
.values()
|
||||
.any(|t| {
|
||||
t.parent_prompt_id.as_deref() == Some(prompt_id)
|
||||
&& t.run_in_background
|
||||
})
|
||||
}
|
||||
/// Record that a foreground child was auto-backgrounded (await budget
|
||||
/// expired): it no longer blocks the turn, so the freeze drain must stop
|
||||
/// waiting on it.
|
||||
pub fn mark_backgrounded(&mut self, subagent_id: &str) {
|
||||
if let Some(t) = self.active.values_mut().find(|t| t.subagent_id == subagent_id)
|
||||
{
|
||||
t.run_in_background = true;
|
||||
}
|
||||
if let Some(p) = self.pending.values_mut().find(|p| p.subagent_id == subagent_id)
|
||||
{
|
||||
p.run_in_background = true;
|
||||
}
|
||||
}
|
||||
pub fn outstanding_reply_for_prompt(
|
||||
&self,
|
||||
prompt_id: &str,
|
||||
) -> xai_grok_tools::implementations::grok_build::task::types::SubagentOutstandingReply {
|
||||
xai_grok_tools::implementations::grok_build::task::types::SubagentOutstandingReply {
|
||||
live_ids: self.outstanding_for_prompt(prompt_id),
|
||||
background_live: self.background_live_for_prompt(prompt_id),
|
||||
subagent_usage_not_applied: self.subagent_usage_not_applied(prompt_id),
|
||||
}
|
||||
}
|
||||
pub fn drain_pending_completions_for(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
) -> Vec<SubagentCompletionSummary> {
|
||||
if session_id.is_empty() {
|
||||
return std::mem::take(&mut self.pending_completions);
|
||||
}
|
||||
let (mine, others) = std::mem::take(&mut self.pending_completions)
|
||||
.into_iter()
|
||||
.partition(|c| {
|
||||
c.owner_session_id.is_empty() || c.owner_session_id == session_id
|
||||
});
|
||||
self.pending_completions = others;
|
||||
mine
|
||||
}
|
||||
pub fn discard_pending_completions_for(&mut self, session_id: &str) {
|
||||
if session_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.pending_completions.retain(|c| c.owner_session_id != session_id);
|
||||
}
|
||||
fn enforce_pending_completions_cap(&mut self) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
/// Collect references to subagents spawned for a specific parent prompt.
|
||||
/// Returns only the children whose `parent_prompt_id` matches, so the
|
||||
/// parent turn's `turn_result.json` accurately reflects what was spawned
|
||||
/// during that turn — not the entire coordinator lifetime.
|
||||
pub fn spawned_refs_for_prompt(&self, prompt_id: &str) -> Vec<SubagentSpawnedRef> {
|
||||
let mut refs: Vec<_> = self
|
||||
.active
|
||||
.values()
|
||||
.filter(|t| t.parent_prompt_id.as_deref() == Some(prompt_id))
|
||||
.map(|t| SubagentSpawnedRef {
|
||||
subagent_id: t.subagent_id.clone(),
|
||||
child_session_id: t.child_session_id.0.to_string(),
|
||||
subagent_type: t.subagent_type.clone(),
|
||||
description: t.description.clone(),
|
||||
persona: t.persona.clone(),
|
||||
resumed_from: t.resumed_from.clone(),
|
||||
})
|
||||
.chain(
|
||||
self
|
||||
.completed
|
||||
.values()
|
||||
.filter(|c| c.parent_prompt_id.as_deref() == Some(prompt_id))
|
||||
.map(|c| SubagentSpawnedRef {
|
||||
subagent_id: c.subagent_id.clone(),
|
||||
child_session_id: c.child_session_id.clone(),
|
||||
subagent_type: c.subagent_type.clone(),
|
||||
description: c.description.clone(),
|
||||
persona: c.persona.clone(),
|
||||
resumed_from: c.resumed_from.clone(),
|
||||
}),
|
||||
)
|
||||
.collect();
|
||||
refs.sort_by(|a, b| a.subagent_id.cmp(&b.subagent_id));
|
||||
refs
|
||||
}
|
||||
/// Register a subagent as pending (initializing). Call this early,
|
||||
/// before any blocking work like worktree creation, so that
|
||||
/// `get_task_output` can report the subagent as initializing instead
|
||||
/// of "not found".
|
||||
pub fn insert_pending(&mut self, entry: PendingSubagent) {
|
||||
self.pending.insert(entry.subagent_id.clone(), entry);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Remove a pending subagent without recording a failure.
|
||||
/// Used by cancel flows where the subagent was intentionally stopped.
|
||||
#[cfg(test)]
|
||||
pub fn remove_pending(&mut self, id: &str) {
|
||||
self.pending.remove(id);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Move a pending subagent directly to `completed` so it stays queryable via
|
||||
/// `get_task_output`. `cancelled` stamps `"cancelled"` vs `"failed"`.
|
||||
fn move_pending_to_terminal(&mut self, id: &str, error: &str, cancelled: bool) {
|
||||
let Some(pending) = self.pending.remove(id) else {
|
||||
return;
|
||||
};
|
||||
self.record_failure_completion(FailureCompletion {
|
||||
subagent_id: pending.subagent_id,
|
||||
subagent_type: pending.subagent_type,
|
||||
description: pending.description,
|
||||
parent_prompt_id: pending.parent_prompt_id,
|
||||
parent_session_id: pending.parent_session_id,
|
||||
owner: pending.owner,
|
||||
persona: pending.persona,
|
||||
started_at: pending.started_at,
|
||||
error,
|
||||
surface_completion: pending.surface_completion,
|
||||
cancelled,
|
||||
});
|
||||
}
|
||||
/// Move a pending subagent to `completed` as a failure so it stays queryable
|
||||
/// via `get_task_output`.
|
||||
pub fn move_pending_to_failed(&mut self, id: &str, error: &str) {
|
||||
self.move_pending_to_terminal(id, error, false);
|
||||
}
|
||||
/// Like [`Self::move_pending_to_failed`] but stamps `"cancelled"` — a pending
|
||||
/// subagent killed while initializing.
|
||||
pub fn move_pending_to_cancelled(&mut self, id: &str, error: &str) {
|
||||
self.move_pending_to_terminal(id, error, true);
|
||||
}
|
||||
/// Record a synthetic failure for a subagent that never reached `pending`.
|
||||
pub fn record_pre_spawn_failure(
|
||||
&mut self,
|
||||
subagent_id: String,
|
||||
subagent_type: String,
|
||||
description: String,
|
||||
parent_prompt_id: Option<String>,
|
||||
parent_session_id: String,
|
||||
owner: SubagentOwner,
|
||||
error: &str,
|
||||
surface_completion: bool,
|
||||
) {
|
||||
self.record_failure_completion(FailureCompletion {
|
||||
subagent_id,
|
||||
subagent_type,
|
||||
description,
|
||||
parent_prompt_id,
|
||||
parent_session_id,
|
||||
owner,
|
||||
persona: None,
|
||||
started_at: std::time::Instant::now(),
|
||||
error,
|
||||
surface_completion,
|
||||
cancelled: false,
|
||||
});
|
||||
}
|
||||
/// Insert a synthetic failed entry, push a completion summary, notify waiters.
|
||||
/// Clears any stale pending entry for the same id.
|
||||
fn record_failure_completion(&mut self, c: FailureCompletion<'_>) {
|
||||
self.pending.remove(&c.subagent_id);
|
||||
self.loop_owned.remove(&c.subagent_id);
|
||||
self.sync_running_gauge();
|
||||
let FailureCompletion {
|
||||
subagent_id,
|
||||
subagent_type,
|
||||
description,
|
||||
parent_prompt_id,
|
||||
parent_session_id,
|
||||
owner,
|
||||
persona,
|
||||
started_at,
|
||||
error,
|
||||
surface_completion,
|
||||
cancelled,
|
||||
} = c;
|
||||
let result = SubagentResult {
|
||||
success: false,
|
||||
cancelled,
|
||||
error: Some(error.to_string()),
|
||||
subagent_id: subagent_id.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let summary_output = result.output.clone();
|
||||
let owner_session_id = parent_session_id.clone();
|
||||
self.completed
|
||||
.insert(
|
||||
subagent_id.clone(),
|
||||
CompletedSubagent {
|
||||
subagent_id: subagent_id.clone(),
|
||||
parent_session_id,
|
||||
parent_prompt_id,
|
||||
owner,
|
||||
child_session_id: String::new(),
|
||||
description: description.clone(),
|
||||
subagent_type: subagent_type.clone(),
|
||||
persona,
|
||||
started_at,
|
||||
completed_at: std::time::Instant::now(),
|
||||
result,
|
||||
resumed_from: None,
|
||||
child_cwd: String::new(),
|
||||
worktree_path: None,
|
||||
snapshot_ref: None,
|
||||
effective_model_id: String::new(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
completion_output_cap: None,
|
||||
persisted_output_dir: None,
|
||||
},
|
||||
);
|
||||
self.enforce_completed_cap();
|
||||
if surface_completion {
|
||||
self.pending_completions
|
||||
.push(SubagentCompletionSummary {
|
||||
subagent_id,
|
||||
owner_session_id,
|
||||
subagent_type,
|
||||
description,
|
||||
success: false,
|
||||
duration_ms: 0,
|
||||
tool_calls: 0,
|
||||
turns: 0,
|
||||
output: summary_output,
|
||||
});
|
||||
self.enforce_pending_completions_cap();
|
||||
}
|
||||
self.completion_notify.notify_waiters();
|
||||
}
|
||||
pub fn insert(&mut self, tracker: SubagentTracker) {
|
||||
self.pending.remove(&tracker.subagent_id);
|
||||
self.active.insert(tracker.subagent_id.clone(), tracker);
|
||||
self.sync_running_gauge();
|
||||
}
|
||||
/// Move a finished subagent from `active` to `completed`.
|
||||
/// Returns the tracker if it was active.
|
||||
pub fn move_to_completed(
|
||||
&mut self,
|
||||
id: &str,
|
||||
description: String,
|
||||
subagent_type: String,
|
||||
result: SubagentResult,
|
||||
persisted_output_dir: Option<PathBuf>,
|
||||
) -> Option<SubagentTracker> {
|
||||
let tracker = self.active.remove(id);
|
||||
self.loop_owned.remove(id);
|
||||
self.sync_running_gauge();
|
||||
let started_at = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.started_at)
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
let parent_session_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.parent_session_id.clone())
|
||||
.unwrap_or_default();
|
||||
let child_session_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.child_session_id.0.to_string())
|
||||
.unwrap_or_default();
|
||||
let parent_prompt_id = tracker.as_ref().and_then(|t| t.parent_prompt_id.clone());
|
||||
let owner = tracker.as_ref().map(|t| t.owner.clone()).unwrap_or_default();
|
||||
let persona = tracker.as_ref().and_then(|t| t.persona.clone());
|
||||
let child_cwd = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.child_cwd.clone())
|
||||
.unwrap_or_default();
|
||||
let worktree_path = tracker.as_ref().and_then(|t| t.worktree_path.clone());
|
||||
let resumed_from = tracker.as_ref().and_then(|t| t.resumed_from.clone());
|
||||
let effective_model_id = tracker
|
||||
.as_ref()
|
||||
.map(|t| t.effective_model_id.clone())
|
||||
.unwrap_or_default();
|
||||
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,
|
||||
parent_prompt_id,
|
||||
owner,
|
||||
child_session_id,
|
||||
description,
|
||||
subagent_type,
|
||||
persona,
|
||||
started_at,
|
||||
completed_at: std::time::Instant::now(),
|
||||
result,
|
||||
resumed_from,
|
||||
child_cwd,
|
||||
worktree_path,
|
||||
snapshot_ref: None,
|
||||
effective_model_id,
|
||||
block_waited,
|
||||
explicitly_killed,
|
||||
completion_output_cap,
|
||||
persisted_output_dir,
|
||||
};
|
||||
let success = completed.result.success && !completed.result.cancelled;
|
||||
{
|
||||
let preview = crate::util::truncate(&completed.result.output, 200);
|
||||
let level_fn = if success {
|
||||
xai_grok_telemetry::unified_log::info
|
||||
} else {
|
||||
xai_grok_telemetry::unified_log::error
|
||||
};
|
||||
level_fn(
|
||||
if success { "subagent completed" } else { "subagent failed" },
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!({
|
||||
"subagent_id": &completed.subagent_id,
|
||||
"subagent_type": &completed.subagent_type,
|
||||
"effective_model": &completed.effective_model_id,
|
||||
"success": success,
|
||||
"cancelled": completed.result.cancelled,
|
||||
"duration_ms": completed.result.duration_ms,
|
||||
"turns": completed.result.turns,
|
||||
"tool_calls": completed.result.tool_calls,
|
||||
"output_preview": preview,
|
||||
"error": &completed.result.error,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
if surface_completion {
|
||||
self.pending_completions
|
||||
.push(SubagentCompletionSummary {
|
||||
subagent_id: id.to_string(),
|
||||
owner_session_id: completed.parent_session_id.clone(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
description: completed.description.clone(),
|
||||
success,
|
||||
duration_ms: completed.result.duration_ms,
|
||||
tool_calls: completed.result.tool_calls,
|
||||
turns: completed.result.turns,
|
||||
output: super::cap_completion_output(
|
||||
&completed.result.output,
|
||||
completed.completion_output_cap,
|
||||
),
|
||||
});
|
||||
self.enforce_pending_completions_cap();
|
||||
}
|
||||
if completed.persisted_output_dir.is_some() {
|
||||
completed.result.output = Arc::from("");
|
||||
}
|
||||
self.completed.insert(id.to_string(), completed);
|
||||
self.enforce_completed_cap();
|
||||
self.completion_notify.notify_waiters();
|
||||
tracker
|
||||
}
|
||||
/// Record the durable worktree snapshot ref on a completed subagent so
|
||||
/// in-memory `resume_from` resolution can rehydrate the disposed worktree.
|
||||
/// No-op if the entry was already evicted (the on-disk meta.json still has it).
|
||||
pub fn set_completed_snapshot_ref(&mut self, id: &str, snapshot_ref: String) {
|
||||
if let Some(completed) = self.completed.get_mut(id) {
|
||||
completed.snapshot_ref = Some(snapshot_ref);
|
||||
}
|
||||
}
|
||||
/// Cancel all active subagents that were launched by a specific parent turn,
|
||||
/// including `run_in_background: true` subagents.
|
||||
pub fn cancel_by_parent_prompt_id(&mut self, parent_prompt_id: &str) {
|
||||
for tracker in self.active.values() {
|
||||
if tracker.parent_prompt_id.as_deref() == Some(parent_prompt_id) {
|
||||
Self::cancel_tracker(tracker);
|
||||
}
|
||||
}
|
||||
for pending in self.pending.values() {
|
||||
if pending.parent_prompt_id.as_deref() == Some(parent_prompt_id) {
|
||||
pending.cancel_token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn cancel_workflow_children(&mut self, run_id: &str) -> usize {
|
||||
for tracker in self.active.values() {
|
||||
if tracker.owner.workflow_run_id() == Some(run_id) {
|
||||
Self::cancel_tracker(tracker);
|
||||
}
|
||||
}
|
||||
for pending in self.pending.values() {
|
||||
if pending.owner.workflow_run_id() == Some(run_id) {
|
||||
pending.cancel_token.cancel();
|
||||
}
|
||||
}
|
||||
self.outstanding_for_workflow(run_id)
|
||||
}
|
||||
pub fn outstanding_for_workflow(&self, run_id: &str) -> usize {
|
||||
self
|
||||
.pending
|
||||
.values()
|
||||
.filter(|entry| entry.owner.workflow_run_id() == Some(run_id))
|
||||
.count()
|
||||
+ self
|
||||
.active
|
||||
.values()
|
||||
.filter(|entry| entry.owner.workflow_run_id() == Some(run_id))
|
||||
.count()
|
||||
}
|
||||
/// Attempt to cancel a subagent. Returns a typed outcome covering all cases:
|
||||
/// - Active → cancel it, return Cancelled
|
||||
/// - Pending (initializing) → fire its spawn token, return Cancelled
|
||||
/// - Already finished → return AlreadyFinished with terminal status
|
||||
/// - Unknown ID → return NotFound
|
||||
pub fn cancel_with_outcome(&mut self, subagent_id: &str) -> SubagentCancelOutcome {
|
||||
if let Some(tracker) = self.active.get(subagent_id) {
|
||||
Self::cancel_tracker(tracker);
|
||||
return SubagentCancelOutcome::Cancelled;
|
||||
}
|
||||
if let Some(pending) = self.pending.get(subagent_id) {
|
||||
pending.cancel_token.cancel();
|
||||
return SubagentCancelOutcome::Cancelled;
|
||||
}
|
||||
if let Some(entry) = self.completed.get(subagent_id) {
|
||||
return SubagentCancelOutcome::AlreadyFinished {
|
||||
status: entry.result.status().to_string(),
|
||||
};
|
||||
}
|
||||
SubagentCancelOutcome::NotFound
|
||||
}
|
||||
/// Internal: send Cancel + Shutdown to a tracked subagent.
|
||||
fn cancel_tracker(tracker: &SubagentTracker) {
|
||||
tracker.cancel_token.cancel();
|
||||
let _ = tracker
|
||||
.child_handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::Cancel {
|
||||
cancel_subagents: true,
|
||||
kill_background_tasks: true,
|
||||
rewind_if_pristine: false,
|
||||
trigger: None,
|
||||
});
|
||||
let _ = tracker.child_handle.cmd_tx.send(SessionCommand::Shutdown);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,427 +0,0 @@
|
|||
#![cfg_attr(rustfmt, rustfmt::skip)]
|
||||
#![allow(unused_imports)]
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use agent_client_protocol as acp;
|
||||
use tokio::sync::{Notify, mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
||||
use crate::session::{
|
||||
self, SessionCommand, SessionHandle, SessionThread,
|
||||
commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult},
|
||||
fs_watch::FsWatchCapabilities, info::Info as SessionInfo,
|
||||
};
|
||||
use crate::terminal::AsyncTerminalRunner;
|
||||
use crate::tools::ToolContext;
|
||||
use crate::upload::trace::{
|
||||
GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata,
|
||||
local_sandbox_telemetry, upload_metadata, upload_session_state,
|
||||
upload_subagent_metadata, upload_turn_result,
|
||||
};
|
||||
use crate::upload::turn::{PromptTraceContext, complete_prompt_trace};
|
||||
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
use xai_grok_tools::implementations::grok_build::task::types::*;
|
||||
use xai_grok_workspace::file_system::AsyncFileSystem;
|
||||
use xai_hunk_tracker::HunkTrackerHandle;
|
||||
use super::*;
|
||||
impl SubagentCoordinator {
|
||||
/// Synchronous lookup of a subagent by ID.
|
||||
///
|
||||
/// Returns a three-way result so the caller can drop the `RefCell` borrow
|
||||
/// before awaiting the signals handle for running subagents.
|
||||
///
|
||||
/// - `Ready` — completed/failed/cancelled snapshot, no async work needed.
|
||||
/// - `NeedsSignals` — subagent is running; caller must await
|
||||
/// `resolve_snapshot()` after dropping the coordinator borrow.
|
||||
/// - `None` — ID not found in active, completed, or pending maps.
|
||||
pub(crate) fn lookup(&self, id: &str) -> Option<SnapshotLookup> {
|
||||
if let Some(tracker) = self.active.get(id) {
|
||||
if tracker.owner.is_workflow() {
|
||||
return None;
|
||||
}
|
||||
return Some(
|
||||
SnapshotLookup::NeedsSignals(RunningSnapshotSeed {
|
||||
subagent_id: tracker.subagent_id.clone(),
|
||||
description: tracker.description.clone(),
|
||||
subagent_type: tracker.subagent_type.clone(),
|
||||
started_at_epoch_ms: instant_to_epoch_ms(tracker.started_at),
|
||||
duration_ms: tracker.started_at.elapsed().as_millis() as u64,
|
||||
persona: tracker.persona.clone(),
|
||||
signals_handle: tracker.child_handle.signals_handle.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(completed) = self.completed.get(id) {
|
||||
if completed.owner.is_workflow() {
|
||||
return None;
|
||||
}
|
||||
let status = if completed.result.cancelled {
|
||||
SubagentSnapshotStatus::Cancelled {
|
||||
reason: completed.result.error.clone(),
|
||||
}
|
||||
} else if completed.result.success {
|
||||
let output = match &completed.persisted_output_dir {
|
||||
Some(dir) => {
|
||||
read_subagent_output(dir)
|
||||
.unwrap_or_else(|| {
|
||||
OUTPUT_UNAVAILABLE_PLACEHOLDER.to_string()
|
||||
})
|
||||
}
|
||||
None => completed.result.output.to_string(),
|
||||
};
|
||||
SubagentSnapshotStatus::Completed {
|
||||
output,
|
||||
tool_calls: completed.result.tool_calls,
|
||||
turns: completed.result.turns,
|
||||
worktree_path: completed.result.worktree_path.clone(),
|
||||
}
|
||||
} else {
|
||||
SubagentSnapshotStatus::Failed {
|
||||
error: completed
|
||||
.result
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown error".to_string()),
|
||||
}
|
||||
};
|
||||
return Some(
|
||||
SnapshotLookup::Ready(SubagentSnapshot {
|
||||
subagent_id: completed.subagent_id.clone(),
|
||||
description: completed.description.clone(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
status,
|
||||
started_at_epoch_ms: instant_to_epoch_ms(completed.started_at),
|
||||
duration_ms: completed.result.duration_ms,
|
||||
persona: completed.persona.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(pending) = self.pending.get(id) {
|
||||
if pending.owner.is_workflow() {
|
||||
return None;
|
||||
}
|
||||
return Some(
|
||||
SnapshotLookup::Ready(SubagentSnapshot {
|
||||
subagent_id: pending.subagent_id.clone(),
|
||||
description: pending.description.clone(),
|
||||
subagent_type: pending.subagent_type.clone(),
|
||||
status: SubagentSnapshotStatus::Initializing,
|
||||
started_at_epoch_ms: instant_to_epoch_ms(pending.started_at),
|
||||
duration_ms: pending.started_at.elapsed().as_millis() as u64,
|
||||
persona: pending.persona.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
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.
|
||||
pub(crate) fn session_ids_for(&self, id: &str) -> Option<(String, String)> {
|
||||
if let Some(t) = self.active.get(id) {
|
||||
return Some((t.parent_session_id.clone(), t.child_session_id.0.to_string()));
|
||||
}
|
||||
if let Some(c) = self.completed.get(id) {
|
||||
return Some((c.parent_session_id.clone(), c.child_session_id.clone()));
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Mark a subagent as block-waited so auto-wake is suppressed on completion.
|
||||
pub(crate) fn mark_block_waited(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.block_waited = true;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.block_waited = true;
|
||||
}
|
||||
}
|
||||
/// Clear the block-waited flag after a block timed out without receiving
|
||||
/// the completion, so auto-wake can still fire when the subagent finishes.
|
||||
pub(crate) fn clear_block_waited(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.block_waited = false;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.block_waited = false;
|
||||
}
|
||||
}
|
||||
/// Whether a block-waiter already consumed this subagent's result.
|
||||
pub(crate) fn is_block_waited(&self, id: &str) -> bool {
|
||||
self.active.get(id).is_some_and(|t| t.block_waited)
|
||||
|| self.completed.get(id).is_some_and(|c| c.block_waited)
|
||||
}
|
||||
/// Register a live blocking-query reply slot and mark `block_waited`.
|
||||
///
|
||||
/// The slot lets `block_wait_delivered_or_live` verify at completion
|
||||
/// time that the waiter can still receive the result — the flag alone
|
||||
/// can be stale when the waiting turn was cancelled moments before the
|
||||
/// subagent finished.
|
||||
pub(crate) fn register_block_wait(&mut self, id: &str, slot: BlockWaitSlot) {
|
||||
self.mark_block_waited(id);
|
||||
self.block_wait_slots.entry(id.to_string()).or_default().push(slot);
|
||||
}
|
||||
/// Drop a previously registered reply slot (query poll loop exited).
|
||||
pub(crate) fn unregister_block_wait(&mut self, id: &str, slot: &BlockWaitSlot) {
|
||||
if let Some(slots) = self.block_wait_slots.get_mut(id) {
|
||||
slots.retain(|s| !std::rc::Rc::ptr_eq(s, slot));
|
||||
if slots.is_empty() {
|
||||
self.block_wait_slots.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Decision-time gate for the completion auto-wake: returns true when
|
||||
/// the result was already delivered to a blocking waiter, or a live
|
||||
/// waiter is still parked and will receive it. When every registered
|
||||
/// waiter is gone (receivers dropped by a cancelled turn), clears
|
||||
/// `block_waited` and returns false so the auto-wake fires.
|
||||
///
|
||||
/// This closes the race where the query poll loop clears the flag up to
|
||||
/// one poll interval *after* the caller cancelled — the completion
|
||||
/// handler could read the stale flag in that window and skip the wake.
|
||||
/// Consumes the id's slot registrations (completion is terminal).
|
||||
pub(crate) fn block_wait_delivered_or_live(&mut self, id: &str) -> bool {
|
||||
let slots = self.block_wait_slots.remove(id).unwrap_or_default();
|
||||
if !self.is_block_waited(id) {
|
||||
return false;
|
||||
}
|
||||
let delivered_or_live = slots.is_empty()
|
||||
|| slots
|
||||
.iter()
|
||||
.any(|s| s.borrow().as_ref().is_none_or(|tx| !tx.is_closed()));
|
||||
if !delivered_or_live {
|
||||
self.clear_block_waited(id);
|
||||
}
|
||||
delivered_or_live
|
||||
}
|
||||
/// Mark a subagent as explicitly killed so auto-wake is suppressed on completion.
|
||||
pub(crate) fn mark_explicitly_killed(&mut self, id: &str) {
|
||||
if let Some(t) = self.active.get_mut(id) {
|
||||
t.explicitly_killed = true;
|
||||
} else if let Some(c) = self.completed.get_mut(id) {
|
||||
c.explicitly_killed = true;
|
||||
}
|
||||
}
|
||||
/// Whether the model explicitly killed this subagent via the kill tool.
|
||||
pub(crate) fn is_explicitly_killed(&self, id: &str) -> bool {
|
||||
self.active.get(id).is_some_and(|t| t.explicitly_killed)
|
||||
|| self.completed.get(id).is_some_and(|c| c.explicitly_killed)
|
||||
}
|
||||
/// Return fork provenance for a given subagent.
|
||||
pub(crate) fn provenance_for(&self, id: &str) -> SubagentProvenance {
|
||||
if let Some(t) = self.active.get(id) {
|
||||
return SubagentProvenance {
|
||||
fork_parent_prompt_id: t.parent_prompt_id.clone(),
|
||||
resumed_from: t.resumed_from.clone(),
|
||||
};
|
||||
}
|
||||
if let Some(c) = self.completed.get(id) {
|
||||
return SubagentProvenance {
|
||||
fork_parent_prompt_id: c.parent_prompt_id.clone(),
|
||||
resumed_from: c.resumed_from.clone(),
|
||||
};
|
||||
}
|
||||
SubagentProvenance::default()
|
||||
}
|
||||
/// Resolve a completed subagent scoped to the requesting parent session.
|
||||
///
|
||||
/// Returns `None` if the subagent is not found, still active, or belongs
|
||||
/// to a different parent session (prevents cross-session context bleed).
|
||||
///
|
||||
/// Fast path: checks the in-memory `completed` map first. When that
|
||||
/// misses (e.g. after cap eviction), falls back to on-disk metadata
|
||||
/// in `{parent_session_dir}/subagents/{id}/meta.json`.
|
||||
pub(crate) fn resumable_source_for(
|
||||
&self,
|
||||
id: &str,
|
||||
parent_session_id: &str,
|
||||
parent_cwd: &Path,
|
||||
) -> Option<ResumeSourceData> {
|
||||
if let Some(completed) = self.completed.get(id) {
|
||||
if completed.parent_session_id != parent_session_id {
|
||||
return None;
|
||||
}
|
||||
return Some(ResumeSourceData {
|
||||
subagent_id: completed.subagent_id.clone(),
|
||||
child_session_id: completed.child_session_id.clone(),
|
||||
child_cwd: completed.child_cwd.clone(),
|
||||
worktree_path: completed.worktree_path.clone(),
|
||||
snapshot_ref: completed.snapshot_ref.clone(),
|
||||
subagent_type: completed.subagent_type.clone(),
|
||||
persona: completed.persona.clone(),
|
||||
model_id: Some(completed.effective_model_id.clone()),
|
||||
});
|
||||
}
|
||||
let parent_info = SessionInfo {
|
||||
id: acp::SessionId::new(parent_session_id),
|
||||
cwd: parent_cwd.to_string_lossy().to_string(),
|
||||
};
|
||||
let meta_path = session::persistence::session_dir(&parent_info)
|
||||
.join("subagents")
|
||||
.join(id)
|
||||
.join("meta.json");
|
||||
let data = std::fs::read_to_string(&meta_path).ok()?;
|
||||
let meta: SubagentMeta = serde_json::from_str(&data).ok()?;
|
||||
if meta.parent_session_id != parent_session_id {
|
||||
return None;
|
||||
}
|
||||
match meta.status.as_str() {
|
||||
"completed" | "failed" | "cancelled" => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some(ResumeSourceData {
|
||||
subagent_id: meta.subagent_id,
|
||||
child_session_id: meta.child_session_id,
|
||||
child_cwd: meta.child_cwd.unwrap_or_default(),
|
||||
worktree_path: meta.worktree_path.map(PathBuf::from),
|
||||
snapshot_ref: meta.snapshot_ref,
|
||||
subagent_type: meta.subagent_type,
|
||||
persona: meta.persona,
|
||||
model_id: meta.effective_model_id,
|
||||
})
|
||||
}
|
||||
/// Check whether an ID refers to a currently-active (running) subagent.
|
||||
pub(crate) fn is_active(&self, id: &str) -> bool {
|
||||
self.active.contains_key(id)
|
||||
}
|
||||
/// Whether the coordinator still has this id in flight (spawning or running).
|
||||
/// Orphan reconcile skips these — there is nothing stuck to heal.
|
||||
pub(crate) fn is_active_or_pending(&self, id: &str) -> bool {
|
||||
self.active.contains_key(id) || self.pending.contains_key(id)
|
||||
}
|
||||
pub(crate) fn record_loop_owner(&mut self, subagent_id: &str, task_id: &str) {
|
||||
self.loop_owned.insert(subagent_id.to_string(), task_id.to_string());
|
||||
}
|
||||
pub(crate) fn remove_loop_owner(&mut self, subagent_id: &str) {
|
||||
self.loop_owned.remove(subagent_id);
|
||||
}
|
||||
pub(crate) fn loop_task_id_of_child_session(
|
||||
&self,
|
||||
child_session_id: &str,
|
||||
) -> Option<String> {
|
||||
let subagent_id = self
|
||||
.active
|
||||
.values()
|
||||
.find(|t| t.child_session_id.0.as_ref() == child_session_id)?
|
||||
.subagent_id
|
||||
.clone();
|
||||
self.loop_owned.get(&subagent_id).cloned()
|
||||
}
|
||||
pub(crate) fn loop_unit_active(&self, task_id: &str) -> bool {
|
||||
self.loop_owned.values().any(|t| t == task_id)
|
||||
}
|
||||
/// The terminal `SubagentFinished` for an id the coordinator already holds in
|
||||
/// `completed`, else `None`. Lets orphan reconcile re-emit a subagent's real
|
||||
/// outcome when only its terminal meta write was lost (reconnect race: entry
|
||||
/// in `completed` but the on-disk meta is still `running`) instead of
|
||||
/// force-cancelling it and discarding the result.
|
||||
pub(crate) fn completed_finish(&self, id: &str) -> Option<SessionUpdate> {
|
||||
let c = self.completed.get(id)?;
|
||||
let duration_ms = c
|
||||
.completed_at
|
||||
.saturating_duration_since(c.started_at)
|
||||
.as_millis() as u64;
|
||||
Some(SessionUpdate::SubagentFinished {
|
||||
subagent_id: c.subagent_id.clone(),
|
||||
child_session_id: c.child_session_id.clone(),
|
||||
status: c.result.status().to_string(),
|
||||
error: c.result.error.clone(),
|
||||
tool_calls: c.result.tool_calls,
|
||||
turns: c.result.turns,
|
||||
duration_ms,
|
||||
tokens_used: 0,
|
||||
output: None,
|
||||
will_wake: false,
|
||||
})
|
||||
}
|
||||
/// Lifecycle-map entry counts as `(pending, active, completed)`.
|
||||
pub(crate) fn registry_snapshot(&self) -> (usize, usize, usize) {
|
||||
(self.pending.len(), self.active.len(), self.completed.len())
|
||||
}
|
||||
/// Oldest completions are evicted first; their `output.json` stays on disk.
|
||||
pub fn enforce_completed_cap(&mut self) {
|
||||
if self.completed.len() <= MAX_COMPLETED_ENTRIES {
|
||||
return;
|
||||
}
|
||||
let excess = self.completed.len() - MAX_COMPLETED_ENTRIES;
|
||||
let mut by_age: Vec<(std::time::Instant, String)> = self
|
||||
.completed
|
||||
.iter()
|
||||
.map(|(id, e)| (e.completed_at, id.clone()))
|
||||
.collect();
|
||||
by_age.sort_unstable_by_key(|(completed_at, _)| *completed_at);
|
||||
for (_, id) in by_age.into_iter().take(excess) {
|
||||
self.completed.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Snapshot all currently-running subagents for compaction state context.
|
||||
///
|
||||
/// Completed/failed/cancelled subagents are NOT included — they live in
|
||||
/// the `completed` map and are irrelevant for post-compaction reminders
|
||||
/// (the model already saw their tool results before compaction).
|
||||
///
|
||||
/// The `elapsed_ms` field is computed from `started_at.elapsed()` at call
|
||||
/// time, so the values are a snapshot of "right now" — appropriate for
|
||||
/// compaction since it happens once and the reminder is static.
|
||||
#[cfg(test)]
|
||||
pub fn active_summaries(&self) -> Vec<ActiveSubagentSummary> {
|
||||
self.active
|
||||
.values()
|
||||
.filter(|t| !t.owner.is_workflow())
|
||||
.map(tracker_to_summary)
|
||||
.collect()
|
||||
}
|
||||
pub fn active_summaries_for(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Vec<ActiveSubagentSummary> {
|
||||
self.active
|
||||
.values()
|
||||
.filter(|t| {
|
||||
t.parent_session_id == parent_session_id && !t.owner.is_workflow()
|
||||
})
|
||||
.map(tracker_to_summary)
|
||||
.collect()
|
||||
}
|
||||
///
|
||||
/// Each seed carries copied identity metadata plus a cloned
|
||||
/// `SessionSignalsHandle` so the caller can resolve live progress
|
||||
/// asynchronously after dropping the coordinator borrow.
|
||||
///
|
||||
/// Returns an empty `Vec` if no active subagents match the given
|
||||
/// parent session ID. Callers (e.g. the `x.ai/subagent/list_running`
|
||||
/// ACP handler) should treat an empty result as a normal "no running
|
||||
/// subagents" response, not an error.
|
||||
pub(crate) fn list_running_for_parent(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Vec<RunningSubagentListSeed> {
|
||||
self.active
|
||||
.values()
|
||||
.filter(|t| {
|
||||
t.parent_session_id == parent_session_id && !t.owner.is_workflow()
|
||||
})
|
||||
.map(|t| RunningSubagentListSeed {
|
||||
subagent_id: t.subagent_id.clone(),
|
||||
parent_session_id: t.parent_session_id.clone(),
|
||||
child_session_id: t.child_session_id.0.to_string(),
|
||||
subagent_type: t.subagent_type.clone(),
|
||||
description: t.description.clone(),
|
||||
started_at_epoch_ms: instant_to_epoch_ms(t.started_at),
|
||||
duration_ms: t.started_at.elapsed().as_millis() as u64,
|
||||
signals_handle: t.child_handle.signals_handle.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue