Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,6 @@
//! ToolBridge: re-exported from `xai-grok-tools`.
//!
//! The bridge implementation now lives in `xai_grok_tools::bridge`.
//! This module re-exports everything for backward compatibility.
pub use xai_grok_tools::bridge::{ToolBridge, ToolBridgeResult};

View file

@ -0,0 +1,732 @@
use crate::models;
use serde::{Deserialize, Serialize};
use xai_grok_sampler::SamplerConfig;
use xai_grok_tools::implementations::grok_build;
use xai_grok_tools::registry::types::ToolConfig;
/// Production grok-build foreground command-timeout ceiling (seconds). The
/// tool-server binary defaults to a 5-minute foreground ceiling
/// (`DEFAULT_MAX_TIMEOUT_MS`); production opts *up* to 10h by sending this
/// explicitly (overridable via config.toml). Bounds only foreground commands —
/// background tasks are always unbounded.
pub const PRODUCTION_MAX_TIMEOUT_SECS: f64 = 36_000.0; // 10 hours
/// User configurable settings for the built-in bash tool (`[toolset.bash]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BashToolConfig {
pub timeout_secs: Option<f64>,
/// Foreground ceiling for model-provided command timeouts (seconds). When
/// `None`, `to_bash_params_json` emits the production default
/// ([`PRODUCTION_MAX_TIMEOUT_SECS`], 10h), opting up from the tool-server
/// binary's 5-minute built-in default. Set it lower to cap foreground
/// commands further. Bounds foreground only; background tasks are always
/// unbounded. Tools run in-process so this is always honored — no server
/// version gate needed.
pub max_timeout_secs: Option<f64>,
pub output_byte_limit: Option<usize>,
pub cmd_prefix: Option<String>,
/// Whether to auto-background a command when it times out (default: `true`).
pub auto_background_on_timeout: Option<bool>,
/// Max FG block before auto-bg when `auto_background_on_timeout` is on
/// (milliseconds). `None` → server default 15s; `Some(0)` → no short budget
/// (auto-bg only at model/default timeout).
pub foreground_block_budget_ms: Option<u64>,
/// Whether to allow a background `&` operator in foreground commands
/// (default: `true`). Resolution: config.toml (this) > remote settings > `true`.
pub allow_background_operator: Option<bool>,
/// Declared so the unknown-key scan accepts `[toolset.bash] persistent_shell`;
/// the effective value is resolved (layered) by `resolve_persistent_local_shell`.
pub persistent_shell: Option<bool>,
}
impl BashToolConfig {
/// Build the JSON params map for the bash tool.
///
/// `remote_auto_bg` is the remote settings fallback for `auto_background_on_timeout`
/// and `remote_allow_background_operator` for `allow_background_operator`.
/// Resolution: local config.toml > remote fallback > `true`.
pub fn to_bash_params_json(
&self,
remote_auto_bg: Option<bool>,
remote_allow_background_operator: Option<bool>,
) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
if let Some(t) = self.timeout_secs {
map.insert("timeout_secs".into(), t.into());
}
// The tool-server binary defaults the foreground ceiling to 5 min;
// production grok-build opts up to 10h by sending it explicitly
// (overridable via config.toml). Foreground-only; background stays unbounded.
let max_timeout_secs = self.max_timeout_secs.unwrap_or(PRODUCTION_MAX_TIMEOUT_SECS);
map.insert("max_timeout_secs".into(), max_timeout_secs.into());
if let Some(limit) = self.output_byte_limit {
map.insert("output_byte_limit".into(), limit.into());
}
if let Some(ref p) = self.cmd_prefix {
map.insert("cmd_prefix".into(), p.clone().into());
}
let auto_bg = self
.auto_background_on_timeout
.or(remote_auto_bg)
.unwrap_or(true);
map.insert("auto_background_on_timeout".into(), auto_bg.into());
if let Some(ms) = self.foreground_block_budget_ms {
map.insert("foreground_block_budget_ms".into(), ms.into());
}
let allow_bg_op = self
.allow_background_operator
.or(remote_allow_background_operator)
.unwrap_or(true);
map.insert("allow_background_operator".into(), allow_bg_op.into());
map
}
}
/// User configurable settings for the ask_user_question tool
/// (`[toolset.ask_user_question]`).
///
/// Consumed out-of-band by
/// `crate::util::config::resolve_ask_user_question_params_from_disk`, which
/// reads the raw config layers so the documented precedence (requirements >
/// env > user > managed > remote) holds — this struct exists so the keys are
/// recognized in `config.toml` and round-trip through `AgentConfig`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AskUserQuestionToolConfig {
/// Whether the questionnaire timeout is armed (default: `true`).
/// `false` waits forever for answers.
pub timeout_enabled: Option<bool>,
/// Wait budget in seconds when the timer is armed (positive integer;
/// default: 1800 / 30 minutes).
pub timeout_secs: Option<u64>,
}
/// User configurable settings for the web_fetch tool (`[toolset.web_fetch]`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct WebFetchToolConfig {
/// Egress proxy endpoint. When set, all HTTP requests are routed through
/// this URL. Resolution: TOML > `GROK_WEB_FETCH_PROXY` env > remote settings > None.
pub proxy_endpoint: Option<String>,
/// Domains the tool is allowed to fetch. When set, overrides the built-in
/// default allowlist. An explicit empty list blocks all fetches.
/// Resolution: TOML > remote settings > built-in defaults.
pub allowed_domains: Option<Vec<String>>,
}
impl WebFetchToolConfig {
/// Resolve `WebFetchParams` by merging TOML > env > remote settings layers.
///
/// `remote_proxy` and `remote_domains` are the remote settings fallback values
/// from `RemoteSettings`. `context_window` comes from the session's
/// SamplingConfig (model-provided).
pub fn resolve_params(
&self,
remote_proxy: Option<&str>,
remote_domains: Option<&[String]>,
context_window_tokens: Option<u64>,
) -> xai_grok_tools::implementations::grok_build::web_fetch::WebFetchParams {
use crate::agent::config::env_string;
let proxy_endpoint = self
.proxy_endpoint
.as_ref()
.cloned()
.or_else(|| env_string("GROK_WEB_FETCH_PROXY"))
.or_else(|| remote_proxy.map(|s| s.to_owned()));
let allowed_domains = self
.allowed_domains
.as_ref()
.cloned()
.or_else(|| remote_domains.map(|d| d.to_vec()));
xai_grok_tools::implementations::grok_build::web_fetch::WebFetchParams {
proxy_endpoint,
allowed_domains,
context_window_tokens,
..Default::default()
}
}
}
/// Top-level toolset configuration for the shell layer.
///
/// This is the *shell-side* config that holds sampling-level settings
/// (e.g., web search API key from the sampling client). It is distinct
/// from `xai_grok_tools::registry::types::ToolsetConfig` which holds
/// tool-implementation-level config (bash limits, web search mode).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ShellToolsetConfig {
pub bash: BashToolConfig,
pub web_search: SamplerConfig,
/// Web fetch tool parameters (`[toolset.web_fetch]`).
#[serde(default)]
pub web_fetch: WebFetchToolConfig,
/// Ask-user-question tool parameters (`[toolset.ask_user_question]`).
#[serde(default)]
pub ask_user_question: AskUserQuestionToolConfig,
/// Which file-operation toolset to use: `"standard"` (default) or `"hashline"`.
#[serde(default)]
pub file_toolset: FileToolset,
/// Hashline scheme parameters. Only used when `file_toolset = "hashline"`.
#[serde(default)]
pub hashline: HashlineSchemeConfig,
}
impl Default for ShellToolsetConfig {
fn default() -> Self {
Self::new(None, None)
}
}
/// Web-search-specific sampling overrides applied on top of a base `SamplerConfig`.
pub(crate) fn web_search_sampling_config(base: SamplerConfig) -> SamplerConfig {
let model = if base.model.is_empty() {
models::default_web_search_model().to_string()
} else {
base.model.clone()
};
SamplerConfig {
model,
max_completion_tokens: Some(8192),
temperature: Some(0.1),
top_p: Some(0.95),
force_http1: false,
max_retries: None,
..base
}
}
impl ShellToolsetConfig {
/// Optionally layers sampling credentials onto the web search config.
pub fn new(base: Option<Self>, sampling_config: Option<SamplerConfig>) -> Self {
let default_base = SamplerConfig {
api_key: None,
base_url: "https://api.x.ai/v1".to_string(),
model: String::new(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
auth_scheme: Default::default(),
extra_headers: indexmap::IndexMap::new(),
context_window: 256_000,
client_version: None,
reasoning_effort: None,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
client_identifier: None,
deployment_id: None,
user_id: None,
origin_client: None,
// Default base for the in-process web-search tool config.
// Real `SamplerConfig`s (e.g. from `sampling_config_for_model`)
// overwrite this entire struct via the `..base` pattern in
// `web_search_sampling_config`, so leaving the callback
// `None` here is fine -- it is only the placeholder for the
// "no base provided" path. The live attribution
// wiring lives at the production SamplerConfig sites in
// agent/config.rs and acp_session.rs.
attribution_callback: None,
bearer_resolver: None,
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
doom_loop_recovery: None,
header_injector: None,
};
let mut toolset = base.unwrap_or_else(|| Self {
bash: BashToolConfig::default(),
web_search: web_search_sampling_config(default_base),
web_fetch: WebFetchToolConfig::default(),
ask_user_question: AskUserQuestionToolConfig::default(),
file_toolset: FileToolset::default(),
hashline: HashlineSchemeConfig::default(),
});
if let Some(sc) = sampling_config {
toolset.web_search = web_search_sampling_config(sc);
}
toolset
}
/// Returns true if web search is enabled based on config.
pub fn web_search_enabled(&self) -> bool {
self.web_search.api_key.is_some()
}
/// Resolve the effective file toolset. Local config takes precedence;
/// remote `/v1/settings` is used as fallback when local is the default.
pub fn resolve_file_toolset(
&self,
remote: Option<&crate::util::config::RemoteSettings>,
) -> FileToolset {
if self.file_toolset != FileToolset::Standard {
return self.file_toolset;
}
if let Some(remote) = remote
&& remote.file_toolset.as_deref() == Some("hashline")
{
return FileToolset::Hashline;
}
self.file_toolset
}
}
// ---------------------------------------------------------------------------
// File toolset selection
// ---------------------------------------------------------------------------
/// Configuration for hashline anchor scheme parameters.
///
/// Configurable in `config.toml` under `[toolset.hashline]`:
/// ```toml
/// [toolset]
/// file_toolset = "hashline"
///
/// [toolset.hashline]
/// scheme = "chunk" # "chunk" (default) or "content_only"
/// hash_len = 3 # anchor hash length (1-4, default 3)
/// chunk_size = 8 # chunk size for chunk scheme (default 8)
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HashlineSchemeConfig {
/// Active scheme: `"chunk"` (default) or `"content_only"`.
pub scheme: String,
/// Anchor hash length in characters (1-4).
pub hash_len: usize,
/// Chunk size for the chunk scheme.
pub chunk_size: usize,
}
impl Default for HashlineSchemeConfig {
fn default() -> Self {
Self {
scheme: "chunk".to_owned(),
hash_len: 3,
chunk_size: 8,
}
}
}
impl HashlineSchemeConfig {
/// Validate the config. Returns an error message if invalid.
pub fn validate(&self) -> Result<(), String> {
match self.scheme.as_str() {
"chunk" | "content_only" => {}
other => {
return Err(format!(
"unknown hashline scheme \"{other}\": expected \"chunk\" or \"content_only\""
));
}
}
if self.hash_len == 0 || self.hash_len > 4 {
return Err(format!(
"hashline hash_len must be 1..=4, got {}",
self.hash_len
));
}
if self.scheme == "chunk" && self.chunk_size == 0 {
return Err("hashline chunk_size must be > 0".to_owned());
}
Ok(())
}
}
/// Which set of read/edit/search tools to use for file operations.
///
/// Selects between the standard `GrokBuild` toolset (`read_file`,
/// `search_replace`, `grep`) and the anchor-based `GrokBuildHashline`
/// toolset (`hashline_read`, `hashline_edit`, `hashline_grep`).
/// The two are mutually exclusive.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileToolset {
/// Standard toolset: read_file, search_replace, grep.
#[default]
Standard,
/// Hashline toolset: hashline_read, hashline_edit, hashline_grep.
Hashline,
}
impl FileToolset {
/// Return the `ToolConfig` entries for the read/edit/search tools
/// belonging to this toolset. For hashline, the scheme params are
/// threaded into each tool's params.
/// Returns an error if hashline config is invalid (upfront validation).
pub fn tool_configs(
self,
hashline_config: &HashlineSchemeConfig,
) -> Result<Vec<ToolConfig>, String> {
match self {
Self::Standard => Ok(vec![
ToolConfig::for_tool::<grok_build::ReadFileTool>(),
ToolConfig::for_tool::<grok_build::SearchReplaceTool>(),
ToolConfig::for_tool::<grok_build::GrepTool>(),
]),
Self::Hashline => {
hashline_config.validate()?;
let params_json = serde_json::json!({
"scheme": hashline_config.scheme,
"hash_len": hashline_config.hash_len,
"chunk_size": hashline_config.chunk_size,
});
let params_map = match params_json {
serde_json::Value::Object(m) => Some(m),
_ => None,
};
Ok(vec![
ToolConfig {
id: "GrokBuildHashline:hashline_read".to_owned(),
params: params_map.clone(),
name_override: None,
params_name_overrides: None,
description_override: None,
behavior_version: None,
kind: None,
},
ToolConfig {
id: "GrokBuildHashline:hashline_edit".to_owned(),
params: params_map.clone(),
name_override: None,
params_name_overrides: None,
description_override: None,
behavior_version: None,
kind: None,
},
ToolConfig {
id: "GrokBuildHashline:hashline_grep".to_owned(),
params: params_map,
name_override: None,
params_name_overrides: None,
description_override: None,
behavior_version: None,
kind: None,
},
])
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_toolset_default_is_standard() {
assert_eq!(FileToolset::default(), FileToolset::Standard);
}
#[test]
fn standard_toolset_configs() {
let configs = FileToolset::Standard
.tool_configs(&HashlineSchemeConfig::default())
.unwrap();
assert_eq!(configs.len(), 3);
let ids: Vec<&str> = configs.iter().map(|c| c.id.as_str()).collect();
assert!(ids.contains(&"GrokBuild:read_file"));
assert!(ids.contains(&"GrokBuild:search_replace"));
assert!(ids.contains(&"GrokBuild:grep"));
}
#[test]
fn hashline_toolset_configs() {
let configs = FileToolset::Hashline
.tool_configs(&HashlineSchemeConfig::default())
.unwrap();
assert_eq!(configs.len(), 3);
let ids: Vec<&str> = configs.iter().map(|c| c.id.as_str()).collect();
assert!(ids.contains(&"GrokBuildHashline:hashline_read"));
assert!(ids.contains(&"GrokBuildHashline:hashline_edit"));
assert!(ids.contains(&"GrokBuildHashline:hashline_grep"));
}
/// Plan/explore omit `search_replace` by contract ("no Write/Edit/
/// MultiEdit"); the hashline override must not hand it back as
/// `hashline_edit`.
#[test]
fn file_toolset_override_never_grants_edit_to_read_only_toolsets() {
let file_tools = FileToolset::Hashline
.tool_configs(&HashlineSchemeConfig::default())
.unwrap();
for mut def in [
xai_grok_agent::config::AgentDefinition::plan(),
xai_grok_agent::config::AgentDefinition::explore(),
] {
let name = def.name.clone();
assert!(
!def.tool_config
.tools
.iter()
.any(|t| t.id == "GrokBuild:search_replace"),
"{name}: fixture must be read-only before the override"
);
def.override_file_tools(file_tools.clone());
let ids: Vec<&str> = def
.tool_config
.tools
.iter()
.map(|t| t.id.as_str())
.collect();
// The swap engages (read moves to hashline)...
assert!(
ids.contains(&"GrokBuildHashline:hashline_read"),
"{name}: {ids:?}"
);
assert!(!ids.contains(&"GrokBuild:read_file"), "{name}: {ids:?}");
// ...but never grants the edit slot.
assert!(
!ids.contains(&"GrokBuildHashline:hashline_edit"),
"{name}: override granted an edit tool to a no-edit toolset: {ids:?}"
);
}
}
#[test]
fn hashline_configs_carry_scheme_params() {
let cfg = HashlineSchemeConfig {
scheme: "content_only".to_owned(),
hash_len: 2,
chunk_size: 16,
};
let configs = FileToolset::Hashline.tool_configs(&cfg).unwrap();
for tc in &configs {
let params = tc
.params
.as_ref()
.expect("hashline tools should have params");
assert_eq!(
params.get("scheme").and_then(|v| v.as_str()),
Some("content_only")
);
assert_eq!(params.get("hash_len").and_then(|v| v.as_u64()), Some(2));
assert_eq!(params.get("chunk_size").and_then(|v| v.as_u64()), Some(16));
}
}
#[test]
fn file_toolset_deserializes_from_string() {
let standard: FileToolset = serde_json::from_str("\"standard\"").unwrap();
assert_eq!(standard, FileToolset::Standard);
let hashline: FileToolset = serde_json::from_str("\"hashline\"").unwrap();
assert_eq!(hashline, FileToolset::Hashline);
}
// -- resolve_file_toolset precedence tests ---
#[test]
fn resolve_local_hashline_wins_over_remote() {
let cfg = ShellToolsetConfig {
file_toolset: FileToolset::Hashline,
..ShellToolsetConfig::default()
};
// Local says hashline — remote is irrelevant.
assert_eq!(cfg.resolve_file_toolset(None), FileToolset::Hashline,);
}
#[test]
fn resolve_remote_used_when_local_default() {
let cfg = ShellToolsetConfig::default(); // Standard (default)
let remote = crate::util::config::RemoteSettings {
file_toolset: Some("hashline".to_owned()),
..Default::default()
};
assert_eq!(
cfg.resolve_file_toolset(Some(&remote)),
FileToolset::Hashline,
);
}
#[test]
fn resolve_default_when_no_remote() {
let cfg = ShellToolsetConfig::default();
assert_eq!(cfg.resolve_file_toolset(None), FileToolset::Standard,);
}
#[test]
fn invalid_hashline_config_rejected_upfront() {
let bad = HashlineSchemeConfig {
scheme: "bogus".to_owned(),
hash_len: 3,
chunk_size: 8,
};
let err = FileToolset::Hashline.tool_configs(&bad);
assert!(err.is_err(), "unknown scheme should be rejected upfront");
assert!(err.unwrap_err().contains("unknown"));
}
#[test]
fn invalid_hash_len_rejected_upfront() {
let bad = HashlineSchemeConfig {
scheme: "chunk".to_owned(),
hash_len: 0,
chunk_size: 8,
};
assert!(FileToolset::Hashline.tool_configs(&bad).is_err());
}
// -- resolve_params precedence tests ---
#[test]
fn resolve_params_toml_wins_over_remote() {
let local = WebFetchToolConfig {
proxy_endpoint: Some("https://toml-proxy.example.com".to_owned()),
allowed_domains: Some(vec!["toml.example.com".to_owned()]),
};
let params = local.resolve_params(
Some("https://remote-proxy.example.com"),
Some(&["remote.example.com".to_owned()]),
None,
);
assert_eq!(
params.proxy_endpoint.as_deref(),
Some("https://toml-proxy.example.com")
);
assert_eq!(
params.allowed_domains,
Some(vec!["toml.example.com".to_owned()])
);
}
#[test]
fn resolve_params_remote_used_when_toml_absent() {
let local = WebFetchToolConfig::default();
let params = local.resolve_params(
Some("https://remote-proxy.example.com"),
Some(&["remote.example.com".to_owned()]),
None,
);
assert_eq!(
params.proxy_endpoint.as_deref(),
Some("https://remote-proxy.example.com")
);
assert_eq!(
params.allowed_domains,
Some(vec!["remote.example.com".to_owned()])
);
}
#[test]
fn resolve_params_defaults_when_nothing_set() {
let local = WebFetchToolConfig::default();
let params = local.resolve_params(None, None, None);
assert!(params.proxy_endpoint.is_none());
assert!(params.allowed_domains.is_none());
}
#[test]
fn resolve_params_toml_empty_domains_blocks_all() {
let local = WebFetchToolConfig {
proxy_endpoint: None,
allowed_domains: Some(vec![]),
};
let params = local.resolve_params(None, Some(&["remote.example.com".to_owned()]), None);
assert_eq!(params.allowed_domains, Some(vec![]));
}
// -- to_bash_params_json allow_background_operator precedence (local > remote > true) --
fn allow_bg_op(map: &serde_json::Map<String, serde_json::Value>) -> Option<bool> {
map.get("allow_background_operator")
.and_then(|v| v.as_bool())
}
#[test]
fn allow_background_operator_local_wins_over_remote() {
let local = BashToolConfig {
allow_background_operator: Some(false),
..BashToolConfig::default()
};
assert_eq!(
allow_bg_op(&local.to_bash_params_json(None, Some(true))),
Some(false)
);
}
#[test]
fn allow_background_operator_remote_used_when_local_absent() {
let local = BashToolConfig::default();
assert_eq!(
allow_bg_op(&local.to_bash_params_json(None, Some(false))),
Some(false)
);
}
#[test]
fn allow_background_operator_defaults_true_when_unset() {
let local = BashToolConfig::default();
assert_eq!(
allow_bg_op(&local.to_bash_params_json(None, None)),
Some(true)
);
}
// -- max_timeout_secs: production sets the 10h foreground ceiling explicitly
// (also the binary default); overridable via config.toml --
fn max_timeout(map: &serde_json::Map<String, serde_json::Value>) -> Option<f64> {
map.get("max_timeout_secs").and_then(|v| v.as_f64())
}
#[test]
fn max_timeout_secs_defaults_to_production_10h() {
let local = BashToolConfig::default();
assert_eq!(
max_timeout(&local.to_bash_params_json(None, None)),
Some(PRODUCTION_MAX_TIMEOUT_SECS),
"production grok-build must set the 10h foreground ceiling"
);
}
#[test]
fn max_timeout_secs_local_override_wins() {
let local = BashToolConfig {
max_timeout_secs: Some(300.0),
..BashToolConfig::default()
};
assert_eq!(
max_timeout(&local.to_bash_params_json(None, None)),
Some(300.0),
);
}
// -- foreground_block_budget_ms: only emitted when set (server defaults to 15s) --
fn fg_budget(map: &serde_json::Map<String, serde_json::Value>) -> Option<u64> {
map.get("foreground_block_budget_ms")
.and_then(|v| v.as_u64())
}
#[test]
fn foreground_block_budget_ms_omitted_by_default() {
let local = BashToolConfig::default();
assert!(
fg_budget(&local.to_bash_params_json(None, None)).is_none(),
"unset budget must not be sent (server keeps 15s default)"
);
}
#[test]
fn foreground_block_budget_ms_local_override_emitted() {
let local = BashToolConfig {
foreground_block_budget_ms: Some(0),
..BashToolConfig::default()
};
assert_eq!(fg_budget(&local.to_bash_params_json(None, None)), Some(0),);
let local = BashToolConfig {
foreground_block_budget_ms: Some(30_000),
..BashToolConfig::default()
};
assert_eq!(
fg_budget(&local.to_bash_params_json(None, None)),
Some(30_000),
);
}
}

View file

@ -0,0 +1,22 @@
//! Tool infrastructure for xai-grok-shell.
//!
//! All tool execution goes through `xai-grok-tools` via the `ToolBridge`.
//! Types (ToolOutput, ToolInput, TodoState, etc.) come from `xai-grok-tools` directly.
pub mod bridge;
pub mod config;
pub mod notification_bridge;
pub mod retry;
pub mod todo;
pub mod tool_context;
pub use self::{
config::{BashToolConfig, FileToolset, ShellToolsetConfig},
retry::{RetryConfig, execute_with_retry},
tool_context::ToolContext,
};
// Re-export key types from xai-grok-tools for convenience
pub use self::todo::{TodoId, TodoItem, TodoPriority, TodoStatus};
pub use xai_grok_tools::types::output::ToolOutput;
pub use xai_grok_tools::types::{MCPToolInput, ToolInput};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
//! Retry utilities — re-exported from `xai-grok-tools`.
//!
//! The canonical implementation now lives in `xai_grok_tools::retry`.
//! This module re-exports with backward-compatible aliases.
pub use xai_grok_tools::retry::BackoffConfig as RetryConfig;
pub use xai_grok_tools::retry::{BackoffConfig, execute_with_backoff};
use std::future::Future;
use std::time::Duration;
/// Backward-compatible wrapper around `execute_with_backoff` that uses
/// `anyhow::Error` as the error type (matching the old signature).
pub async fn execute_with_retry<T, E, EFut, R, RFut>(
config: &RetryConfig,
execute: E,
on_retry: R,
) -> Result<T, anyhow::Error>
where
E: FnMut() -> EFut,
EFut: Future<Output = Result<T, anyhow::Error>>,
R: FnMut(u32, u32, Duration) -> RFut,
RFut: Future<Output = ()>,
{
execute_with_backoff(config, execute, on_retry).await
}

View file

@ -0,0 +1,81 @@
//! Todo types — re-exported from `xai-grok-tools` with ACP conversion helpers.
//!
//! Types are canonical in `xai-grok-tools`. This module adds ACP ↔ TodoItem
//! conversions since `xai-grok-tools` is protocol-agnostic.
pub use xai_grok_tools::implementations::grok_build::todo::TodoId;
pub use xai_grok_tools::implementations::grok_build::todo::TodoItem;
pub use xai_grok_tools::implementations::grok_build::todo::TodoPriority;
pub use xai_grok_tools::implementations::grok_build::todo::TodoState;
pub use xai_grok_tools::implementations::grok_build::todo::TodoStatus;
use agent_client_protocol as acp;
/// Convert an ACP `PlanEntry` to a `TodoItem`.
///
/// Handles the cancelled state: ACP has no `Cancelled` status, so cancelled
/// items are stored as `Completed` with `{"cancelled": true}` in meta.
pub fn todo_item_from_plan_entry(entry: acp::PlanEntry) -> TodoItem {
let status = match entry.status {
acp::PlanEntryStatus::Pending => TodoStatus::Pending,
acp::PlanEntryStatus::InProgress => TodoStatus::InProgress,
acp::PlanEntryStatus::Completed => {
// Check if this is actually a cancelled item
if entry
.meta
.as_ref()
.and_then(|m| m.get("cancelled"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
TodoStatus::Cancelled
} else {
TodoStatus::Completed
}
}
// TODO(acp-0.10): `PlanEntryStatus` is #[non_exhaustive].
_ => TodoStatus::Pending,
};
TodoItem {
content: entry.content,
priority: match entry.priority {
acp::PlanEntryPriority::High => TodoPriority::High,
acp::PlanEntryPriority::Medium => TodoPriority::Medium,
acp::PlanEntryPriority::Low => TodoPriority::Low,
// TODO(acp-0.10): `PlanEntryPriority` is #[non_exhaustive].
_ => TodoPriority::Medium,
},
status,
meta: entry.meta.map(serde_json::Value::Object),
}
}
/// Convert a `TodoItem` to an ACP `PlanEntry`.
///
/// Cancelled items become `Completed` with `{"cancelled": true}` in meta.
pub fn plan_entry_from_todo_item(item: TodoItem) -> acp::PlanEntry {
let status = match item.status {
TodoStatus::Pending => acp::PlanEntryStatus::Pending,
TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
TodoStatus::Completed => acp::PlanEntryStatus::Completed,
TodoStatus::Cancelled => acp::PlanEntryStatus::Completed,
};
let mut meta = item.meta;
if item.status == TodoStatus::Cancelled {
let mut m = meta.unwrap_or_else(|| serde_json::json!({}));
if let Some(obj) = m.as_object_mut() {
obj.insert("cancelled".into(), true.into());
}
meta = Some(m);
}
acp::PlanEntry::new(
item.content,
match item.priority {
TodoPriority::High => acp::PlanEntryPriority::High,
TodoPriority::Medium => acp::PlanEntryPriority::Medium,
TodoPriority::Low => acp::PlanEntryPriority::Low,
},
status,
)
.meta(meta.and_then(|v| v.as_object().cloned()))
}

View file

@ -0,0 +1,249 @@
//! Session context — legacy name "ToolContext".
//!
//! This struct holds session-level state (cwd, gateway, filesystem, hunk tracker, etc.)
//! that the session actor needs for non-tool operations (ACP communication, git, rewind, etc.).
//!
//! Tool execution goes through the ToolBridge, which has its own SessionContext from
//! xai-grok-tools. This struct is NOT used for tool execution — it's session infrastructure.
//!
//! Note: Could be renamed to `SessionConfig` or flattened onto `SessionActor` in a future PR.
use crate::terminal::AsyncTerminalRunner;
use agent_client_protocol as acp;
use std::collections::HashMap;
use std::sync::Arc;
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
use xai_grok_paths::AbsPathBuf;
use xai_grok_workspace::file_system::{AsyncFileSystem, AsyncFsWrapper};
use xai_grok_workspace::session::file_state::FileStateHandle;
use xai_hunk_tracker::HunkTrackerHandle;
/// RAII marker: the turn is blocked inside an interruptible wait. Increments
/// [`ToolContext::blocking_wait_depth`] for its lifetime; `Drop` decrements
/// (a cancelled turn can't leak the count).
pub(crate) struct BlockingWaitGuard(Arc<std::sync::atomic::AtomicUsize>);
impl BlockingWaitGuard {
pub(crate) fn enter(depth: Arc<std::sync::atomic::AtomicUsize>) -> Self {
depth.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Self(depth)
}
}
impl Drop for BlockingWaitGuard {
fn drop(&mut self) {
let _ = self.0.fetch_update(
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
|depth| Some(depth.saturating_sub(1)),
);
}
}
/// Session-level context. NOT used for tool execution (bridge handles that).
/// Holds ACP gateway, cwd, hunk tracker, etc. for session infrastructure.
#[derive(Clone)]
pub struct ToolContext {
pub gateway: Option<GatewaySender>,
pub session_id: Option<acp::SessionId>,
pub fs: AsyncFsWrapper,
pub terminal: Arc<dyn AsyncTerminalRunner>,
pub cwd: AbsPathBuf,
pub file_state_handle: Option<FileStateHandle>,
pub session_env: Arc<HashMap<String, String>>,
pub hunk_tracker_handle: HunkTrackerHandle,
/// Whether hunk tracking is active for this session. `false` when the
/// resolved mode is `off`/`disabled` — `hunk_tracker_handle` is then a
/// `noop()` and the fs-notify loop skips forwarding to avoid per-event cost.
pub hunk_tracking_enabled: bool,
pub prompt_index: Arc<tokio::sync::Mutex<usize>>,
/// Current subagent nesting depth for this session.
/// Top-level sessions start at 0; child sessions are parent_depth + 1.
pub subagent_depth: u32,
/// Unified subagent event sender — carries spawn, query, cancel,
/// list-active, completions, and outstanding messages to the coordinator.
/// `None` if subagent support is not enabled.
pub subagent_event_tx: Option<
tokio::sync::mpsc::UnboundedSender<
xai_grok_tools::implementations::grok_build::task::types::SubagentEvent,
>,
>,
/// Shared LSP runtime — cloned cheaply (Arc) from parent to child.
/// Same pattern as `fs` and `terminal`.
pub lsp: Option<Arc<dyn xai_grok_tools::implementations::lsp::LspBackend>>,
/// LSP server names snapshot from session creation (not updated mid-session).
pub lsp_server_names: Vec<String>,
/// Shared turn-active flag — set `true` at turn start, `false` at turn end.
/// Used by the between-turn completion drain in `handle_prompt`.
pub is_turn_active: Option<Arc<std::sync::atomic::AtomicBool>>,
/// Shared buffer for mid-turn monitor event notifications.
/// Events pushed here are drained by the session turn loop
/// (`inject_pending_monitor_events`) and surfaced as ONE hidden
/// synthetic user message before the next sampling step.
pub monitor_event_buffer:
Option<xai_grok_tools::implementations::grok_build::task::types::MonitorEventBuffer>,
/// Shared set of IDs delivered via auto-wake synthetic prompts.
/// Used by `TaskCompletionReminder` to suppress duplicate reminders.
pub auto_wake_delivered:
Option<xai_grok_tools::reminders::task_completion::AutoWakeDeliveredIds>,
/// Channel for requesting trace uploads for synthetic auto-wake turns.
pub(crate) synthetic_trace_tx:
Option<tokio::sync::mpsc::UnboundedSender<crate::upload::turn::SyntheticTurnTraceRequest>>,
/// Shared slot for the synthetic trace channel. Populated by
/// `start_subagent_coordinator` after the notification bridge is spawned.
/// The notification bridge reads from this slot on each completion event.
pub(crate) synthetic_trace_tx_shared: Option<
std::sync::Arc<
std::sync::Mutex<
Option<
tokio::sync::mpsc::UnboundedSender<
crate::upload::turn::SyntheticTurnTraceRequest,
>,
>,
>,
>,
>,
/// Resolved name of the `BackgroundTaskAction` tool in the current toolset.
/// Used by auto-wake to format completion messages with the correct tool name.
pub task_output_tool_name: String,
/// Whether auto-wake is enabled. When `false`, background task and subagent
/// completions fall back to the idle-gated notification drain.
pub auto_wake_enabled: bool,
/// When set, bash + subagent auto-wake synthetic prompts are suppressed.
/// Shared `Arc` written at one chokepoint — see
/// `SessionActor::set_goal_loop_active_resource` for the rationale.
pub goal_loop_active_gate: Arc<std::sync::atomic::AtomicBool>,
/// Count of interruptible blocking waits the running turn is parked in (via
/// [`BlockingWaitGuard`]). `queue_input` reads it: a prompt arriving while
/// non-zero takes the send-now path.
pub blocking_wait_depth: Arc<std::sync::atomic::AtomicUsize>,
}
impl ToolContext {
pub fn new(
cwd: AbsPathBuf,
gateway: Option<GatewaySender>,
session_id: Option<acp::SessionId>,
fs: Arc<dyn AsyncFileSystem>,
terminal: Arc<dyn AsyncTerminalRunner>,
hunk_tracker_handle: HunkTrackerHandle,
) -> Self {
let session_env = xai_grok_workspace::envrc::load_envrc_or_empty_when_trusted(
cwd.as_path(),
crate::agent::folder_trust::project_scope_allowed(cwd.as_path()),
);
Self {
gateway,
session_id,
fs: AsyncFsWrapper::new(fs),
terminal,
cwd,
file_state_handle: None,
session_env: Arc::new(session_env),
hunk_tracker_handle,
hunk_tracking_enabled: true,
prompt_index: Arc::new(tokio::sync::Mutex::new(0)),
subagent_depth: 0,
subagent_event_tx: None,
lsp: None,
lsp_server_names: Vec::new(),
is_turn_active: None,
monitor_event_buffer: None,
auto_wake_delivered: None,
synthetic_trace_tx: None,
synthetic_trace_tx_shared: None,
task_output_tool_name:
xai_grok_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL.to_string(),
auto_wake_enabled: true,
goal_loop_active_gate: Arc::new(std::sync::atomic::AtomicBool::new(false)),
blocking_wait_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
pub fn with_preloaded_env(
cwd: AbsPathBuf,
gateway: Option<GatewaySender>,
session_id: Option<acp::SessionId>,
fs: Arc<dyn AsyncFileSystem>,
terminal: Arc<dyn AsyncTerminalRunner>,
hunk_tracker_handle: HunkTrackerHandle,
session_env: HashMap<String, String>,
) -> Self {
Self {
gateway,
session_id,
fs: AsyncFsWrapper::new(fs),
terminal,
cwd,
file_state_handle: None,
session_env: Arc::new(session_env),
hunk_tracker_handle,
hunk_tracking_enabled: true,
prompt_index: Arc::new(tokio::sync::Mutex::new(0)),
subagent_depth: 0,
subagent_event_tx: None,
lsp: None,
lsp_server_names: Vec::new(),
is_turn_active: None,
monitor_event_buffer: None,
auto_wake_delivered: None,
synthetic_trace_tx: None,
synthetic_trace_tx_shared: None,
task_output_tool_name:
xai_grok_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL.to_string(),
auto_wake_enabled: true,
goal_loop_active_gate: Arc::new(std::sync::atomic::AtomicBool::new(false)),
blocking_wait_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
pub fn with_file_state_handle(mut self, handle: FileStateHandle) -> Self {
self.file_state_handle = Some(handle);
self
}
pub fn with_prompt_index(mut self, prompt_index: Arc<tokio::sync::Mutex<usize>>) -> Self {
self.prompt_index = prompt_index;
self
}
/// Set whether hunk tracking is active. `false` pairs with a `noop()`
/// `hunk_tracker_handle` so the fs-notify loop skips the per-event forward.
pub fn with_hunk_tracking_enabled(mut self, enabled: bool) -> Self {
self.hunk_tracking_enabled = enabled;
self
}
}
#[cfg(test)]
mod tests {
use crate::{terminal::AsyncTerminalRunner, tools::ToolContext};
use std::collections::HashMap;
use std::sync::Arc;
use xai_grok_paths::AbsPathBuf;
use xai_grok_workspace::file_system::{AsyncFileSystem, AsyncFsWrapper};
use xai_hunk_tracker::HunkTrackerHandle;
impl ToolContext {
pub fn new_local_context(
cwd: AbsPathBuf,
fs: Arc<dyn AsyncFileSystem>,
terminal: Arc<dyn AsyncTerminalRunner>,
) -> Self {
Self {
gateway: None,
session_id: None,
fs: AsyncFsWrapper::new(fs),
terminal,
cwd,
file_state_handle: None,
session_env: Arc::new(HashMap::new()),
hunk_tracker_handle: HunkTrackerHandle::noop(),
hunk_tracking_enabled: true,
prompt_index: Arc::new(tokio::sync::Mutex::new(0)),
subagent_depth: 0,
subagent_event_tx: None,
lsp: None,
lsp_server_names: Vec::new(),
is_turn_active: None,
monitor_event_buffer: None,
auto_wake_delivered: None,
synthetic_trace_tx: None,
synthetic_trace_tx_shared: None,
task_output_tool_name:
xai_grok_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL.to_string(),
auto_wake_enabled: true,
goal_loop_active_gate: Arc::new(std::sync::atomic::AtomicBool::new(false)),
blocking_wait_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
}
}