grok-build-upstream-mirror/crates/codegen/xai-grok-agent/src/builder.rs

2396 lines
99 KiB
Rust
Raw Normal View History

//! AgentBuilder — fluent construction API for building Agents.
use crate::agent::Agent;
use crate::compaction::CompactionPolicy;
use crate::config::{AGENT_TASK_CLASSIFIER_RE, short_tool_name, tool_id_eq, tool_id_matches};
use crate::config::{AgentDefinition, BuiltinAgentName, PermissionMode, PromptMode};
use crate::discovery::{SubagentEntry, SubagentSource};
use crate::error::AgentBuildError;
use crate::prompt::context::PromptContext;
use crate::system_reminder::ReminderPolicy;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use xai_grok_tools::bridge::ToolBridge;
use xai_grok_tools::computer::types::{AsyncFileSystem, TerminalBackend};
use xai_grok_tools::notification::ToolNotificationHandle;
use xai_grok_tools::registry::types::SessionContext;
use xai_grok_tools::types::tool::ToolKind;
/// The Grok [`ToolKind`] a vendor-compat `tools:` allowlist entry resolves to, so
/// a plugin's upstream allowlist still binds. Backed by the shared vendor-to-Grok
/// tool registry in `xai-grok-tools` (also used by the hook matcher).
fn claude_tool_kind(name: &str) -> Option<ToolKind> {
xai_grok_tools::types::kind_for(name)
}
/// Builds an Agent from an AgentDefinition + session context.
///
/// Two main flows:
///
/// // 1. From a definition file
/// let def = AgentDefinition::from_file("agents/code-reviewer.md")?;
/// let agent = AgentBuilder::new(cwd, None, notification_handle)
/// .from_definition(def)
/// .build()
/// .await?;
///
/// // 2. Programmatic (no file)
/// let agent = AgentBuilder::new(cwd, None, notification_handle)
/// .with_name("my-agent")
/// .with_description("A custom agent")
/// .with_tools(vec!["read_file".into(), "grep".into()])
/// .build()
/// .await?;
pub struct AgentBuilder {
working_directory: PathBuf,
/// Model-facing working directory for the system prompt `<user_info>` block.
///
/// In forked sessions, the real `working_directory` is an overlay/worktree
/// path (e.g., `~/.grok/worktrees/project/fork-...-overlay`) that must stay
/// hidden from the model. When set, `PromptContext.working_directory` uses
/// this value instead of `self.working_directory`, so the system prompt
/// shows the original project path. Tool execution (`ToolContext.cwd`,
/// `SessionContext.cwd`) is unaffected and continues to use the real path.
prompt_working_directory: Option<String>,
terminal_backend: Arc<dyn TerminalBackend>,
fs_backend: Arc<dyn AsyncFileSystem>,
notification_handle: ToolNotificationHandle,
owner_session_id: Option<String>,
parent_scheduler_handle:
Option<xai_grok_tools::implementations::grok_build::scheduler::types::SchedulerHandle>,
/// The agent definition — set via from_definition() or built up
/// via individual with_*() calls.
definition: Option<AgentDefinition>,
/// Pre-rendered persona IO summaries for the task tool description.
persona_summaries: Vec<String>,
/// Whether this builder produces a primary or subagent session prompt.
prompt_audience: crate::prompt::context::PromptAudience,
/// Role instructions to inject into the system prompt.
role_instructions: Option<String>,
/// Persona instructions to inject into the system prompt.
persona_instructions: Option<String>,
name: Option<String>,
description: Option<String>,
prompt_mode: PromptMode,
tools: Option<Vec<String>>,
disallowed_tools: Vec<String>,
skill_names: Vec<String>,
permission_mode: PermissionMode,
agents_md: bool,
custom_system_prompt: Option<String>,
compaction_policy: CompactionPolicy,
reminder_policy: ReminderPolicy,
memory_enabled: bool,
memory_global_path: Option<String>,
memory_workspace_path: Option<String>,
is_non_interactive: bool,
system_prompt_label: String,
session_env: Option<Arc<HashMap<String, String>>>,
state_path: Option<PathBuf>,
memory_backend: Option<Arc<dyn xai_grok_tools::types::memory_backend::MemoryBackend>>,
web_search_config: xai_grok_tools::implementations::web_search::WebSearchConfig,
/// When true, web search and X search are sent as native server-side
/// tools for execution by the agentic sampler, instead of being
/// registered as local Function tools.
backend_search: bool,
web_fetch_config: xai_grok_tools::implementations::grok_build::web_fetch::WebFetchConfig,
lsp: Option<std::sync::Arc<dyn xai_grok_tools::implementations::lsp::LspBackend>>,
image_gen_config: xai_grok_tools::implementations::grok_build::image_gen::ImageGenConfig,
video_gen_config: xai_grok_tools::implementations::grok_build::video_gen::VideoGenConfig,
app_builder_deployer_config:
xai_grok_tools::implementations::grok_build::deploy_app::AppBuilderDeployerConfig,
write_file_enabled: bool,
subagents_enabled: bool,
ask_user_question_enabled: bool,
subagent_toggle: HashMap<String, bool>,
task_model_slugs: Vec<String>,
skills_config: crate::prompt::skills::SkillsConfig,
/// Resolved vendor-compat config governing which vendor (`.claude`/`.cursor`)
/// dirs are scanned for skills / rules / AGENTS.md. Defaults to all-on,
/// which reproduces the historical behavior.
compat: xai_grok_tools::types::compat::CompatConfig,
bash_params_json: Option<serde_json::Map<String, serde_json::Value>>,
ask_user_question_params_json: Option<serde_json::Map<String, serde_json::Value>>,
plugin_registry: Option<std::sync::Arc<crate::plugins::PluginRegistry>>,
context_window_tokens: Option<u64>,
api_key_provider: Option<xai_grok_tools::types::SharedApiKeyProvider>,
attribution_callback: Option<xai_grok_tools::SharedAttributionCallback>,
/// Session-scoped MCP tool-result inline cap (bytes). When `Some`, seeded
/// into the toolset's `TruncationCfg` resource after finalize, where the
/// MCP truncation path consults it before the process-global cap. The
/// shell passes the winning repo-level `[mcp] max_output_bytes` here (and
/// only when that tier wins the precedence stack — see
/// `resolve_max_mcp_output_bytes_for_cwd` in xai-grok-shell).
mcp_max_output_bytes: Option<usize>,
/// System-reminder tag name for tool result text. Defaults to `"system-reminder"`.
/// IDE-compat agent_type should set this to `"system_reminder"`.
system_reminder_tag: &'static str,
/// Persisted announced skill names from a previous session.
/// When set, restored into the SkillManager before `seed()` so that
/// `seed()` sees non-empty `announced_names` and skips the
/// `BaselineChange` pending, preventing duplicate system-reminder
/// injection on session resume.
persisted_announced_skill_names: Option<std::collections::HashSet<String>>,
/// Pre-discovered skills inherited from a parent session.
/// When set, `build()` uses these directly instead of running
/// `list_skills_with_plugins()`.
preloaded_skills: Option<Vec<xai_grok_tools::implementations::skills::types::SkillInfo>>,
}
/// Ensure plan mode tools (`enter_plan_mode`, `exit_plan_mode`,
/// `ask_user_question`) are present in the tool config.
fn ensure_plan_mode_tools(tool_config: &mut xai_grok_tools::registry::types::ToolServerConfig) {
use xai_grok_tools::implementations::grok_build;
let existing: std::collections::HashSet<&str> =
tool_config.tools.iter().map(|tc| tc.id.as_str()).collect();
let missing_enter = !existing.contains("GrokBuild:enter_plan_mode");
let missing_exit = !existing.contains("GrokBuild:exit_plan_mode");
let missing_ask = !existing.contains("GrokBuild:ask_user_question");
drop(existing);
if missing_enter {
tool_config
.tools
.push((&grok_build::EnterPlanModeTool).into());
}
if missing_exit {
tool_config
.tools
.push((&grok_build::ExitPlanModeTool).into());
}
if missing_ask {
tool_config
.tools
.push((&grok_build::AskUserQuestionTool).into());
}
}
/// Merge a shell-resolved params map into every matching tool's
/// `ToolConfig.params` (single copy of the loop the per-tool injections share).
fn merge_tool_params(
tool_config: &mut xai_grok_tools::registry::types::ToolServerConfig,
ids: &[&str],
map: &serde_json::Map<String, serde_json::Value>,
) {
for tc in &mut tool_config.tools {
if ids.contains(&tc.id.as_str()) {
let params = tc.params.get_or_insert_with(serde_json::Map::new);
for (k, v) in map {
params.insert(k.clone(), v.clone());
}
}
}
}
impl AgentBuilder {
pub fn new(
working_directory: PathBuf,
terminal_backend: Arc<dyn TerminalBackend>,
notification_handle: ToolNotificationHandle,
) -> Self {
Self {
working_directory,
prompt_working_directory: None,
terminal_backend,
fs_backend: Arc::new(xai_grok_tools::computer::local::LocalFs),
notification_handle,
owner_session_id: None,
parent_scheduler_handle: None,
definition: None,
persona_summaries: Vec::new(),
prompt_audience: crate::prompt::context::PromptAudience::Primary,
role_instructions: None,
persona_instructions: None,
name: None,
description: None,
prompt_mode: PromptMode::Extend,
tools: None,
disallowed_tools: vec![],
skill_names: vec![],
permission_mode: PermissionMode::Default,
agents_md: true,
custom_system_prompt: None,
compaction_policy: CompactionPolicy::default(),
reminder_policy: ReminderPolicy::default(),
memory_enabled: false,
memory_global_path: None,
memory_workspace_path: None,
is_non_interactive: false,
system_prompt_label: crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL.to_string(),
session_env: None,
state_path: None,
memory_backend: None,
web_search_config: Default::default(),
backend_search: false,
web_fetch_config: Default::default(),
lsp: None,
image_gen_config: Default::default(),
video_gen_config: Default::default(),
app_builder_deployer_config: Default::default(),
write_file_enabled: true,
subagents_enabled: false,
ask_user_question_enabled: true,
subagent_toggle: HashMap::new(),
task_model_slugs: Vec::new(),
skills_config: Default::default(),
compat: Default::default(),
bash_params_json: None,
ask_user_question_params_json: None,
plugin_registry: None,
context_window_tokens: None,
api_key_provider: None,
attribution_callback: None,
mcp_max_output_bytes: None,
system_reminder_tag: xai_grok_tools::reminders::DEFAULT_REMINDER_TAG,
persisted_announced_skill_names: None,
preloaded_skills: None,
}
}
/// Set persisted announced skill names for session resume.
///
/// These names are restored into the `SkillManager` inside `build()`,
/// after `ToolBridge::finalize_builder()` but before
/// `seed_skill_discovery()`. This ensures `seed()` sees non-empty
/// `announced_names` and skips setting `pending = BaselineChange`.
pub fn with_persisted_announced_skill_names(
mut self,
names: std::collections::HashSet<String>,
) -> Self {
self.persisted_announced_skill_names = Some(names);
self
}
/// Supply pre-discovered skills from a parent session instead of
/// running filesystem discovery. When set, `build()` skips
/// `list_skills_with_plugins()` and uses the snapshot directly.
pub fn with_preloaded_skills(
mut self,
skills: Vec<xai_grok_tools::implementations::skills::types::SkillInfo>,
) -> Self {
self.preloaded_skills = Some(skills);
self
}
/// Load from a pre-parsed AgentDefinition.
pub fn from_definition(mut self, def: AgentDefinition) -> Self {
self.definition = Some(def);
self
}
/// Set pre-rendered persona IO summaries for the task tool description.
pub fn with_persona_summaries(mut self, summaries: Vec<String>) -> Self {
self.persona_summaries = summaries;
self
}
/// Set the prompt audience (Primary or Subagent).
pub fn with_prompt_audience(
mut self,
audience: crate::prompt::context::PromptAudience,
) -> Self {
self.prompt_audience = audience;
self
}
pub fn with_role_instructions(mut self, instructions: Option<String>) -> Self {
self.role_instructions = instructions;
self
}
pub fn with_persona_instructions(mut self, instructions: Option<String>) -> Self {
self.persona_instructions = instructions;
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_prompt_mode(mut self, mode: PromptMode) -> Self {
self.prompt_mode = mode;
self
}
pub fn with_tools(mut self, tools: Vec<String>) -> Self {
self.tools = Some(tools);
self
}
pub fn with_disallowed_tools(mut self, tools: Vec<String>) -> Self {
self.disallowed_tools = tools;
self
}
pub fn with_skills(mut self, skill_names: Vec<String>) -> Self {
self.skill_names = skill_names;
self
}
pub fn with_permission_mode(mut self, mode: PermissionMode) -> Self {
self.permission_mode = mode;
self
}
pub fn with_agents_md(mut self, enabled: bool) -> Self {
self.agents_md = enabled;
self
}
pub fn with_custom_system_prompt(mut self, prompt: String) -> Self {
self.custom_system_prompt = Some(prompt);
self
}
pub fn with_compaction_policy(mut self, policy: CompactionPolicy) -> Self {
self.compaction_policy = policy;
self
}
pub fn with_memory_enabled(mut self, enabled: bool) -> Self {
self.memory_enabled = enabled;
self
}
pub fn with_memory_paths(
mut self,
global_path: Option<String>,
workspace_path: Option<String>,
) -> Self {
self.memory_global_path = global_path;
self.memory_workspace_path = workspace_path;
self
}
/// Mark this session as non-interactive (headless / SDK / stdio /
/// generic-ACP). Suppresses prompt sections that only make sense when
/// a human is typing into the TUI prompt input (e.g. the `! <command>`
/// shell-prefix tip and the `<user_guide>` TUI pointer).
pub fn with_is_non_interactive(mut self, value: bool) -> Self {
self.is_non_interactive = value;
self
}
pub fn with_system_prompt_label(mut self, label: impl Into<String>) -> Self {
self.system_prompt_label = label.into();
self
}
pub fn with_reminder_policy(mut self, policy: ReminderPolicy) -> Self {
self.reminder_policy = policy;
self
}
pub fn with_session_env(mut self, env: Arc<HashMap<String, String>>) -> Self {
self.session_env = Some(env);
self
}
/// Session-scoped MCP tool-result inline cap (bytes).
///
/// `Some(v)` is seeded into the toolset's `TruncationCfg` resource after
/// finalize, so the MCP truncation path uses `v` instead of the
/// process-global cap. `None` (default) seeds nothing — MCP truncation
/// falls through to the process-global resolution.
pub fn with_mcp_max_output_bytes(mut self, bytes: Option<usize>) -> Self {
self.mcp_max_output_bytes = bytes;
self
}
pub fn with_state_path(mut self, path: PathBuf) -> Self {
self.state_path = Some(path);
self
}
/// Set the memory backend for cross-session knowledge retrieval.
///
/// When set, the `memory_search` and `memory_get` tools can access
/// the indexed memory store. When `None` (default), those tools
/// return "Memory is not enabled".
pub fn with_memory_backend(
mut self,
backend: Arc<dyn xai_grok_tools::types::memory_backend::MemoryBackend>,
) -> Self {
self.memory_backend = Some(backend);
self
}
/// Set a custom filesystem backend for the ToolBridge.
///
/// When `None` (default), tools use `LocalFs` (direct disk I/O).
/// Pass an ACP-backed implementation when the client advertises
/// `clientCapabilities.fs.readTextFile` and `writeTextFile`.
pub fn with_fs(mut self, fs: Arc<dyn AsyncFileSystem>) -> Self {
self.fs_backend = fs;
self
}
/// Set the session ID that owns processes spawned by this session's tools.
pub fn with_owner_session_id(mut self, id: String) -> Self {
self.owner_session_id = Some(id);
self
}
/// Share the parent's scheduler handle so scheduled tasks survive subagent exit.
pub fn with_parent_scheduler_handle(
mut self,
handle: xai_grok_tools::implementations::grok_build::scheduler::types::SchedulerHandle,
) -> Self {
self.parent_scheduler_handle = Some(handle);
self
}
/// Set the web search configuration.
///
/// When `Enabled`, a `WebSearchClient` is created and injected into
/// the ToolBridge's resources so the `web_search` tool can call the
/// Responses API. When `Disabled` (default), the tool returns a
/// graceful error if invoked.
pub fn with_web_search_config(
mut self,
config: xai_grok_tools::implementations::web_search::WebSearchConfig,
) -> Self {
self.web_search_config = config;
self
}
/// When true, web search and X search are sent as native server-side
/// tools for execution by the agentic sampler, instead of being
/// registered as local Function tools. Per-model gating is applied
/// at request time, not here.
pub fn with_backend_search(mut self, enabled: bool) -> Self {
self.backend_search = enabled;
self
}
/// Set the web fetch configuration.
///
/// When `Enabled`, the `web_fetch` tool is registered and a `WebFetchClient`
/// is injected into `Resources`. When `Disabled` (default), the tool is not
/// registered. Feature-flagged via remote settings `web_fetch_enabled` and
/// `GROK_WEB_FETCH` env var.
pub fn with_web_fetch_config(
mut self,
config: xai_grok_tools::implementations::grok_build::web_fetch::WebFetchConfig,
) -> Self {
self.web_fetch_config = config;
self
}
pub fn with_lsp(
mut self,
handle: std::sync::Arc<dyn xai_grok_tools::implementations::lsp::LspBackend>,
) -> Self {
self.lsp = Some(handle);
self
}
/// Set the image generation configuration.
///
/// When `Enabled`, an `ImageGenClient` is created and injected into
/// the ToolBridge's resources and the `image_gen` tool is registered,
/// allowing image generation via the xAI Imagine API with session
/// credentials. When `Disabled` (default), the tool is not registered.
pub fn with_image_gen_config(
mut self,
config: xai_grok_tools::implementations::grok_build::image_gen::ImageGenConfig,
) -> Self {
self.image_gen_config = config;
self
}
/// Set the video generation configuration.
///
/// When `Enabled`, a `VideoGenClient` is created and injected into
/// the ToolBridge's resources and the `video_gen` tool is registered,
/// allowing video generation via the xAI Video Generation API with
/// session credentials. When `Disabled` (default), the tool is not
/// registered.
pub fn with_video_gen_config(
mut self,
config: xai_grok_tools::implementations::grok_build::video_gen::VideoGenConfig,
) -> Self {
self.video_gen_config = config;
self
}
/// Set the deploy service configuration.
pub fn with_app_builder_deployer_config(
mut self,
config: xai_grok_tools::implementations::grok_build::deploy_app::AppBuilderDeployerConfig,
) -> Self {
self.app_builder_deployer_config = config;
self
}
/// Set the dynamic API key provider for tool HTTP clients.
pub fn with_api_key_provider(
mut self,
provider: xai_grok_tools::types::SharedApiKeyProvider,
) -> Self {
self.api_key_provider = Some(provider);
self
}
/// Set the 401-attribution callback for tool HTTP clients
/// (`image_gen`, `video_gen`, `web_search`). When set, a 401
/// from any of those tools emits an `auth_401_attribution`
/// event with `consumer` of `"ImageGen"` / `"VideoGen.start"` /
/// `"VideoGen.poll"` / `"WebSearch"`. Callers should pass the
/// same `ShellAttribution` instance they wire into
/// `xai_grok_sampler::SamplerConfig::attribution_callback` so
/// all 401s share the same `AuthManager` reference and land in
/// the same Axiom dataset.
pub fn with_attribution_callback(
mut self,
callback: xai_grok_tools::SharedAttributionCallback,
) -> Self {
self.attribution_callback = Some(callback);
self
}
/// Override the system-reminder tag name used in tool result text.
///
/// Defaults to `"system-reminder"` (hyphen). Harnesses trained on a
/// different tag name (e.g. an underscore variant) should call this so
/// that reminders match the tag name their model was trained on.
pub fn with_system_reminder_tag(mut self, tag: &'static str) -> Self {
self.system_reminder_tag = tag;
self
}
/// Enable or disable the `write` tool (default: enabled).
pub fn with_write_file_enabled(mut self, enabled: bool) -> Self {
self.write_file_enabled = enabled;
self
}
/// Enable or disable subagent (task tool) support.
///
/// When disabled, the `TaskTool` is stripped from the
/// agent's tool config during `build()`, preventing the model from
/// spawning child agent sessions.
pub fn with_subagents_enabled(mut self, enabled: bool) -> Self {
self.subagents_enabled = enabled;
self
}
/// Set public model slugs advertised in the GrokBuild Task description.
pub fn with_task_model_slugs(mut self, slugs: Vec<String>) -> Self {
self.task_model_slugs = slugs;
self
}
/// Enable or disable the `ask_user_question` tool.
///
/// When disabled, `GrokBuild:ask_user_question` is stripped from the
/// agent's tool config after `ensure_plan_mode_tools` injection, so
/// the model cannot ask the user structured questions regardless of
/// which built-in profile is in use. Driven by the shell's resolved gate
/// (`resolve_ask_user_question`, default ON — remote settings/config/env act as
/// a kill-switch) and/or the pager's `--no-ask-user` (`_meta.askUserQuestion`).
pub fn with_ask_user_question_enabled(mut self, enabled: bool) -> Self {
self.ask_user_question_enabled = enabled;
self
}
/// Set per-subagent enable/disable toggles from `[subagents.toggle]`.
///
/// Keys are agent names, values are booleans. Omitted agents default
/// to enabled. When combined with `with_subagents_enabled(true)`, this
/// controls which individual subagents appear in the Task tool description
/// and are accepted at spawn time.
pub fn with_subagent_toggle(mut self, toggle: HashMap<String, bool>) -> Self {
self.subagent_toggle = toggle;
self
}
/// Set the resolved vendor-compat config. Threaded into both startup
/// discovery (`list_skills_with_plugins` / `read_agents_config_with_paths`)
/// and the dynamic-discovery seeds (`SkillManager` / `AgentsMdTracker`).
pub fn with_compat_config(
mut self,
compat: xai_grok_tools::types::compat::CompatConfig,
) -> Self {
self.compat = compat;
self
}
/// Set the skills config (custom paths, ignore globs) from config.toml.
/// Without this, only auto-discovered skills (cwd/.grok/skills, ~/.grok/skills)
/// are included — custom paths added via `x.ai/skills/add` would be ignored.
pub fn with_skills_config(mut self, config: crate::prompt::skills::SkillsConfig) -> Self {
self.skills_config = config;
self
}
/// Inject `[toolset.bash]` overrides from config.toml into bash tool params.
pub fn with_bash_params(mut self, params: serde_json::Map<String, serde_json::Value>) -> Self {
self.bash_params_json = Some(params);
self
}
/// Inject the shell-resolved `[toolset.ask_user_question]` params
/// (timeout policy) into the ask_user_question tool.
pub fn with_ask_user_question_params(
mut self,
params: serde_json::Map<String, serde_json::Value>,
) -> Self {
self.ask_user_question_params_json = Some(params);
self
}
/// Set the plugin registry for plugin-aware skill/agent discovery.
pub fn with_plugin_registry(
mut self,
registry: std::sync::Arc<crate::plugins::PluginRegistry>,
) -> Self {
self.plugin_registry = Some(registry);
self
}
/// Set the model context window size in tokens.
pub fn with_context_window(mut self, tokens: u64) -> Self {
self.context_window_tokens = Some(tokens);
self
}
/// Override the working directory shown in the system prompt.
///
/// When set, `PromptContext.working_directory` (and therefore the
/// `Workspace Path` in the `<user_info>` block) uses this value instead
/// of the real `working_directory`. Tool execution paths are unaffected.
///
/// Used by forked sessions so the model sees the original project path
/// rather than the internal overlay/worktree path.
pub fn with_prompt_working_directory(mut self, cwd: String) -> Self {
self.prompt_working_directory = Some(cwd);
self
}
fn resolve_definition(&self) -> AgentDefinition {
if let Some(ref def) = self.definition {
return def.clone();
}
let mut def = AgentDefinition::default_grok_build();
if let Some(ref name) = self.name {
def.name = name.clone();
}
if let Some(ref desc) = self.description {
def.description = desc.clone();
}
def.prompt_mode = self.prompt_mode.clone();
def.permission_mode = self.permission_mode.clone();
def.agents_md = self.agents_md;
if let Some(ref prompt) = self.custom_system_prompt {
def.prompt_body = Some(prompt.clone());
}
if !self.skill_names.is_empty() {
def.skills = self.skill_names.clone();
}
if let Some(ref tools) = self.tools {
def.tools = tools.clone();
}
def.disallowed_tools = self.disallowed_tools.clone();
def
}
/// Build the Agent.
///
/// This is the full 10-step build process from the architecture doc.
pub async fn build(mut self) -> Result<Agent, AgentBuildError> {
let mut definition = self.resolve_definition();
let working_dir_str = self.working_directory.to_str().unwrap_or(".").to_string();
let skill_info = if let Some(preloaded) = self.preloaded_skills.take() {
preloaded
} else if definition.discover_skills {
crate::prompt::skills::list_skills_with_plugins(
Some(&working_dir_str),
&self.skills_config,
self.plugin_registry.as_deref(),
self.compat,
)
.await
} else {
vec![]
};
let preloaded_skill_paths: std::collections::HashSet<String> = if !definition
.skills
.is_empty()
{
let preloaded =
crate::prompt::skills::resolve_preloaded_skills(&definition.skills, &skill_info)
.await;
let paths = preloaded.iter().map(|s| s.path.clone()).collect();
if !preloaded.is_empty() {
let injection = crate::prompt::skills::format_skills_for_injection(&preloaded);
if !injection.is_empty() {
definition.prompt_body =
Some(injection + &definition.prompt_body.unwrap_or_default());
}
}
paths
} else {
std::collections::HashSet::new()
};
let tool_bridge_builder = ToolBridge::get_builder();
let state_path = self.state_path.clone().unwrap_or_default();
let mut tool_config = definition.tool_config.clone();
if !definition.inject_default_tools && tool_config.tools.is_empty() {
return Err(AgentBuildError::InvalidConfig(format!(
"agent '{}' declares a curated toolset (inject_default_tools = false) \
but its tool list is empty; if the toolset is a registry preset \
(e.g. the external harness), the provider crate's register() must run \
at process startup before any agent is built",
definition.name
)));
}
if definition.inject_default_tools {
if self.memory_backend.is_some() {
use xai_grok_tools::implementations::memory;
tool_config
.tools
.push((&memory::search_tool::MemorySearchImpl).into());
tool_config
.tools
.push((&memory::get_tool::MemoryGetImpl).into());
}
if self.web_search_config.is_enabled() {
use xai_grok_tools::implementations::grok_build;
tool_config.tools.push((&grok_build::WebSearchTool).into());
}
if self.web_fetch_config.is_enabled() {
use xai_grok_tools::implementations::grok_build;
tool_config.tools.push((&grok_build::WebFetchTool).into());
}
if self.lsp.is_some() {
tool_config
.tools
.push((&xai_grok_tools::implementations::grok_build::LspTool).into());
}
if self.image_gen_config.image_gen_enabled() {
tool_config
.tools
.push((&xai_grok_tools::implementations::grok_build::ImageGenTool).into());
}
if self.image_gen_config.image_edit_enabled() {
tool_config
.tools
.push((&xai_grok_tools::implementations::grok_build::ImageEditTool).into());
}
if self.video_gen_config.is_enabled() {
tool_config
.tools
.push((&xai_grok_tools::implementations::grok_build::ImageToVideoTool).into());
tool_config.tools.push(
(&xai_grok_tools::implementations::grok_build::ReferenceToVideoTool).into(),
);
}
let has_write_tool = tool_config
.tools
.iter()
.any(|tc| tc.id.ends_with(":write") || tc.id.ends_with(":Write"));
if self.write_file_enabled && !has_write_tool {
tool_config
.tools
.push((&xai_grok_tools::implementations::opencode::OpenCodeWriteTool).into());
}
ensure_plan_mode_tools(&mut tool_config);
}
if self.memory_backend.is_none() {
let grok_build_ns = xai_grok_tools::types::tool::ToolNamespace::GrokBuild.to_string();
let mem_search_id = format!(
"{grok_build_ns}:{}",
xai_grok_tools::implementations::memory::MEMORY_SEARCH_TOOL_NAME
);
let mem_get_id = format!(
"{grok_build_ns}:{}",
xai_grok_tools::implementations::memory::MEMORY_GET_TOOL_NAME
);
tool_config
.tools
.retain(|tc| tc.id != mem_search_id && tc.id != mem_get_id);
}
if !self.ask_user_question_enabled {
let ask_user_id = format!(
"{}:ask_user_question",
xai_grok_tools::types::tool::ToolNamespace::GrokBuild,
);
tool_config.tools.retain(|tc| tc.id != ask_user_id);
}
let task_tool_id = format!(
"{}:{}",
xai_grok_tools::types::tool::ToolNamespace::GrokBuild,
"task"
);
let mut task_stripped = false;
if !self.subagents_enabled {
tool_config.tools.retain(|tc| tc.id != task_tool_id);
task_stripped = true;
} else {
let subagents = crate::discovery::all_subagents_with_plugins(
&self.working_directory,
&self.subagent_toggle,
self.plugin_registry.as_deref(),
);
if subagents.is_empty() {
tool_config.tools.retain(|tc| tc.id != task_tool_id);
task_stripped = true;
} else if self.prompt_audience == crate::prompt::context::PromptAudience::Subagent {
if let Some(task_tc) = tool_config
.tools
.iter_mut()
.find(|tc| tc.id == task_tool_id)
{
task_tc.description_override = Some(CHILD_TASK_DESCRIPTION.to_string());
}
} else if let Some(task_tc) = tool_config
.tools
.iter_mut()
.find(|tc| tc.id == task_tool_id)
{
task_tc.description_override =
Some(build_task_description(&subagents, &self.task_model_slugs));
}
}
if task_stripped {
use xai_grok_tools::types::tool::ToolNamespace;
let has_satisfier = |ns: ToolNamespace, id: &str, needs_bg: bool| {
let fq = format!("{ns}:{id}");
tool_config.tools.iter().any(|tc| {
tc.id == fq
&& (!needs_bg
|| tc
.params
.as_ref()
.and_then(|p| p.get("enabled_background"))
.and_then(|v| v.as_bool())
.unwrap_or(true))
})
};
if !has_satisfier(ToolNamespace::GrokBuild, "run_terminal_cmd", true)
&& !has_satisfier(ToolNamespace::GrokBuildConcise, "run_terminal_cmd", true)
&& !has_satisfier(ToolNamespace::OpenCode, "bash", false)
{
let lifecycle = ["get_task_output", "wait_tasks", "kill_task"];
tool_config
.tools
.retain(|tc| !lifecycle.contains(&short_tool_name(&tc.id)));
}
}
if let xai_grok_tools::implementations::grok_build::web_fetch::WebFetchConfig::Enabled {
ref params,
} = self.web_fetch_config
&& let Ok(params_value) = serde_json::to_value(params)
&& let Some(obj) = params_value.as_object()
{
merge_tool_params(&mut tool_config, &["GrokBuild:web_fetch"], obj);
}
if let Some(ref bash_params) = self.bash_params_json {
merge_tool_params(
&mut tool_config,
&[
"GrokBuild:run_terminal_cmd",
"GrokBuildConcise:run_terminal_cmd",
],
bash_params,
);
}
if let Some(ref ask_params) = self.ask_user_question_params_json {
merge_tool_params(
&mut tool_config,
&["GrokBuild:ask_user_question"],
ask_params,
);
}
if !definition.disallowed_tools.is_empty() {
let before: std::collections::HashSet<String> =
tool_config.tools.iter().map(|tc| tc.id.clone()).collect();
tool_config
.tools
.retain(|tc| !tool_id_matches(&definition.disallowed_tools, &tc.id));
let after: std::collections::HashSet<String> =
tool_config.tools.iter().map(|tc| tc.id.clone()).collect();
let removed: std::collections::HashSet<&String> = before.difference(&after).collect();
for d in &definition.disallowed_tools {
if AGENT_TASK_CLASSIFIER_RE.is_match(d) {
continue;
}
let matched = removed.iter().any(|&id| tool_id_eq(d, id));
if !matched {
tracing::warn!(
agent = % definition.name, tool = % d,
"disallowedTools entry matched nothing"
);
}
}
}
if !definition.tools.is_empty() {
let has_agent_entry = definition
.tools
.iter()
.any(|t| AGENT_TASK_CLASSIFIER_RE.is_match(t));
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
let registered_tool_ids = tool_bridge_builder.known_tool_ids();
let present_kinds: std::collections::HashSet<ToolKind> =
tool_config.tools.iter().filter_map(|tc| tc.kind).collect();
let mut allow_kinds: std::collections::HashSet<ToolKind> =
std::collections::HashSet::new();
let mut unresolved: Vec<&str> = Vec::new();
let mut recognized_but_unavailable: Vec<&str> = Vec::new();
for t in &definition.tools {
if AGENT_TASK_CLASSIFIER_RE.is_match(t) {
continue;
}
if t.starts_with("mcp__") {
continue;
}
if tool_config.tools.iter().any(|tc| tool_id_eq(t, &tc.id)) {
continue;
}
match claude_tool_kind(t) {
Some(kind) => {
if present_kinds.contains(&kind) {
allow_kinds.insert(kind);
} else {
recognized_but_unavailable.push(t);
}
}
None if registered_tool_ids.iter().any(|id| tool_id_eq(t, id)) => {
recognized_but_unavailable.push(t);
}
None => unresolved.push(t),
}
}
if !recognized_but_unavailable.is_empty() {
tracing::debug!(
agent = % definition.name, recognized_but_unavailable = ?
recognized_but_unavailable,
"tools allowlist named recognized tools that aren't enabled; ignoring them"
);
}
if unresolved.is_empty() {
tool_config.tools.retain(|tc| {
tool_id_matches(&definition.tools, &tc.id)
|| tc.kind.is_some_and(|k| allow_kinds.contains(&k))
|| (has_agent_entry && task_deps.contains(&short_tool_name(&tc.id)))
|| matches!(tc.kind, Some(ToolKind::SearchTool | ToolKind::UseTool))
});
tracing::debug!(
agent = % definition.name, allowed = ? definition.tools,
"tools allowlist applied"
);
} else {
tracing::warn!(
agent = % definition.name, unresolved = ? unresolved, allowed = ?
definition.tools,
"tools allowlist had unmappable entries; keeping full grok toolset"
);
}
}
tool_config
.tools
.retain(|tc| definition.session_tools_allowed(&tc.id));
{
let mut saw_directive = false;
let types: Vec<String> = definition
.tools
.iter()
.filter_map(|t| {
let caps = AGENT_TASK_CLASSIFIER_RE.captures(t)?;
saw_directive = true;
caps.get(1)
})
.flat_map(|m| m.as_str().split(','))
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
let types = {
let mut seen = std::collections::HashSet::new();
types
.into_iter()
.filter(|t| seen.insert(t.clone()))
.collect::<Vec<_>>()
};
definition.allowed_subagent_types = if !saw_directive && !definition.tools.is_empty() {
Some(vec![])
} else if types.is_empty() {
None
} else {
Some(types)
};
}
if !definition.disallowed_tools.is_empty() {
let has_bare_deny = definition.disallowed_tools.iter().any(|d| {
AGENT_TASK_CLASSIFIER_RE
.captures(d)
.is_some_and(|caps| caps.get(1).is_none_or(|m| m.as_str().trim().is_empty()))
});
if has_bare_deny {
definition.allowed_subagent_types = Some(vec![]);
} else {
let denied_types: Vec<String> = definition
.disallowed_tools
.iter()
.filter_map(|d| AGENT_TASK_CLASSIFIER_RE.captures(d)?.get(1))
.flat_map(|m| m.as_str().split(','))
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if !denied_types.is_empty()
&& let Some(ref mut allowed) = definition.allowed_subagent_types
{
allowed.retain(|t| !denied_types.iter().any(|d| d.eq_ignore_ascii_case(t)));
}
}
}
if definition.allowed_subagent_types.as_deref() == Some(&[]) {
let task_deps = ["task", "get_task_output", "kill_task", "wait_tasks"];
tool_config
.tools
.retain(|tc| !task_deps.contains(&short_tool_name(&tc.id)));
for tc in &mut tool_config.tools {
if short_tool_name(&tc.id) == "run_terminal_cmd" {
let params = tc.params.get_or_insert_with(Default::default);
params.insert("enabled_background".into(), false.into());
params.insert("auto_background_on_timeout".into(), false.into());
}
}
}
let use_backend_search = self.backend_search;
let web_search_enabled = self.web_search_config.is_enabled();
let tool_bridge = ToolBridge::finalize_builder(
tool_bridge_builder,
tool_config,
SessionContext {
backend: self.terminal_backend,
fs: self.fs_backend,
cwd: self.working_directory.clone(),
session_folder: state_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(std::env::temp_dir),
session_env: self.session_env.unwrap_or_default(),
notification_handle: self.notification_handle.clone(),
owner_session_id: self.owner_session_id.clone(),
parent_scheduler_handle: self.parent_scheduler_handle.take(),
skills: skill_info.clone(),
state_path,
memory_backend: self.memory_backend,
web_search_config: self.web_search_config,
web_fetch_config: self.web_fetch_config,
lsp: self.lsp,
image_gen_config: self.image_gen_config,
video_gen_config: self.video_gen_config,
app_builder_deployer_config: self.app_builder_deployer_config,
api_key_provider: self.api_key_provider,
auth_provider: None,
attribution_callback: self.attribution_callback,
system_reminder_tag: self.system_reminder_tag,
},
)
.await
.map_err(|e| AgentBuildError::ToolError(e.to_string()))?;
if let Some(bytes) = self.mcp_max_output_bytes {
tool_bridge.toolset().resources.lock().await.insert(
xai_grok_tools::types::resources::TruncationCfg(
xai_grok_tools::types::context::TruncationConfig {
mcp_max_output_bytes: Some(bytes),
..Default::default()
},
),
);
}
if let Some(names) = self.persisted_announced_skill_names {
tool_bridge.restore_announced_skill_names(names).await;
}
let mut agents_md_files = if definition.agents_md {
crate::prompt::agents_md::read_agents_config_with_paths(&working_dir_str, self.compat)
.await
} else {
vec![]
};
{
let initial_paths: Vec<PathBuf> = agents_md_files
.iter()
.map(|c| PathBuf::from(&c.file_path))
.collect();
let git_root = git2::Repository::discover(&self.working_directory)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()));
let gitignore = crate::prompt::ignore::build_gitignore(git_root.as_deref());
let canonical_cwd = dunce::canonicalize(&self.working_directory)
.unwrap_or_else(|_| self.working_directory.clone());
let canonical_root = git_root.as_ref().and_then(|r| dunce::canonicalize(r).ok());
let chain: Vec<PathBuf> = if let Some(ref root) = canonical_root {
let mut dirs = Vec::new();
let mut current = Some(canonical_cwd.as_path());
while let Some(dir) = current {
dirs.push(dir.to_path_buf());
if dir == root.as_path() {
break;
}
current = dir.parent();
}
dirs
} else {
vec![]
};
if let Some(gi) = gitignore.as_ref()
&& let Some(root) = git_root.as_ref()
{
tool_bridge
.seed_gitignore_filter(gi.clone(), root.clone())
.await;
}
tool_bridge
.seed_agents_md(
initial_paths,
git_root.clone(),
chain,
gitignore,
self.compat,
)
.await;
let listing_skills = if preloaded_skill_paths.is_empty() {
skill_info.clone()
} else {
skill_info
.iter()
.filter(|s| !preloaded_skill_paths.contains(&s.path))
.cloned()
.collect()
};
let skill_budget_percent: Option<f64> = None;
let skill_discovery_cwd = if definition.discover_skills {
Some(self.working_directory.clone())
} else {
None
};
tool_bridge
.seed_skill_discovery(
skill_discovery_cwd,
git_root,
listing_skills,
self.prompt_working_directory.clone(),
self.context_window_tokens,
skill_budget_percent,
self.compat,
)
.await;
}
let now = chrono::Utc::now();
if let Some(ref display_cwd) = self.prompt_working_directory {
for file in &mut agents_md_files {
file.file_path = file.file_path.replace(&working_dir_str, display_cwd);
}
}
let display_working_dir = self
.prompt_working_directory
.unwrap_or_else(|| self.working_directory.to_string_lossy().into_owned());
let prompt_context = PromptContext {
version: 1,
prompt_mode: definition.prompt_mode.clone(),
audience: self.prompt_audience,
prompt_body: definition.prompt_body.clone(),
system_prompt: definition.system_prompt.clone(),
agents_md_files,
persona_summaries: self.persona_summaries,
build_timestamp_utc: now.to_rfc3339(),
memory_enabled: self.memory_enabled,
memory_global_path: self.memory_global_path,
memory_workspace_path: self.memory_workspace_path,
role_instructions: self.role_instructions,
persona_instructions: self.persona_instructions,
os_name: Some(std::env::consts::OS.to_string()),
shell_path: Some(resolve_shell_for_prompt()),
working_directory: Some(display_working_dir),
current_date: Some(
now.with_timezone(&chrono::Local)
.format("%Y-%m-%d")
.to_string(),
),
is_non_interactive: self.is_non_interactive,
system_prompt_label: self.system_prompt_label,
};
let system_prompt = prompt_context
.render(&tool_bridge)
.await
.unwrap_or_default();
if let Some(rendered) = tool_bridge
.render_prompt(&definition.description, &prompt_context.placeholders())
.await
{
definition.description = rendered;
}
let mut hosted_tools = Vec::new();
if use_backend_search {
if web_search_enabled && definition.hosted_tool_allowed("web_search") {
hosted_tools.push(xai_grok_sampling_types::HostedTool::WebSearch {
allowed_domains: None,
});
}
if definition.hosted_tool_allowed("x_search") {
hosted_tools.push(xai_grok_sampling_types::HostedTool::XSearch);
}
}
#[allow(clippy::arc_with_non_send_sync)]
let tool_bridge = Arc::new(tool_bridge);
Ok(Agent::new(
definition,
prompt_context,
system_prompt,
tool_bridge,
self.reminder_policy,
self.compaction_policy,
hosted_tools,
use_backend_search,
))
}
}
/// CLI naming for the shared [`xai_tool_types::build_task_description`] builder.
const TASK_TOOL_NAMING: xai_tool_types::TaskToolNaming<'static> = xai_tool_types::TaskToolNaming {
task_tool: "${{ tools.by_kind.task }}",
subagent_type_param: "${{ params.task.subagent_type }}",
run_in_background_param: "${{ params.task.run_in_background }}",
resume_from_param: "${{ params.task.resume_from }}",
background_retrieval_tool: "${{ tools.by_kind.background_task_action }}",
isolation_param: "${{ params.task.isolation }}",
};
/// Concise task-tool description for child sessions. Delegation from a child
/// is possible but discouraged — prefer doing the work directly.
///
/// NOTE: This hardcodes the built-in agent type names ("general-purpose",
/// "explore", "plan"). If custom child-visible subagent types become common,
/// consider generating this list dynamically like the parent description does.
const CHILD_TASK_DESCRIPTION: &str = "\
Launch a sub-agent to handle a specific sub-task. Use this only when \n\
the sub-task is clearly independent and would benefit from a separate \n\
context (e.g., a parallel search while you continue working).\n\
\n\
Prefer doing the work yourself unless delegation is clearly necessary.\n\
\n\
Usage: specify ${{ params.task.subagent_type }} (\"general-purpose\", \"explore\", or \"plan\"), \n\
a short ${{ params.task.description }}, and a detailed ${{ params.task.prompt }}.\n\
${{ params.task.run_in_background }}: Returns immediately with a subagent_id. Use the task output tool to retrieve results. This is set to true by default.";
/// CLI [`xai_tool_types::SubagentToolNaming`]: each kind maps to its
/// `${{ tools.by_kind.* }}` template placeholder, so rendering a built-in's
/// `tools_template` reproduces the placeholders for the CLI's `TemplateRenderer`
/// to resolve at finalize time.
const SUBAGENT_TOOL_NAMING: xai_tool_types::SubagentToolNaming<'static> =
xai_tool_types::SubagentToolNaming {
execute: "${{ tools.by_kind.execute }}",
read: "${{ tools.by_kind.read }}",
edit: "${{ tools.by_kind.edit }}",
list: "${{ tools.by_kind.list }}",
search: "${{ tools.by_kind.search }}",
web_search: "${{ tools.by_kind.web_search }}",
plan: "${{ tools.by_kind.plan }}",
};
/// Return the tool-access fragment for a built-in subagent type, sourced from the
/// shared [`xai_tool_types`] catalog and rendered with [`SUBAGENT_TOOL_NAMING`]
/// (which re-emits the `${{ tools.by_kind.* }}` placeholders for the CLI's
/// `TemplateRenderer` to resolve at finalize time).
fn builtin_tools_fragment(name: BuiltinAgentName) -> String {
let subagent = match name {
BuiltinAgentName::GeneralPurpose => xai_tool_types::GENERAL_PURPOSE_SUBAGENT,
BuiltinAgentName::Explore => xai_tool_types::EXPLORE_SUBAGENT,
BuiltinAgentName::Plan => xai_tool_types::PLAN_SUBAGENT,
_ => return String::new(),
};
subagent.render_tools(&SUBAGENT_TOOL_NAMING)
}
const TASK_MODEL_PARAM: &str = "${{ params.task.model }}";
fn task_model_guidance(model_slugs: &[String]) -> String {
let mut model_slugs = model_slugs.to_vec();
model_slugs.sort_unstable();
model_slugs.dedup();
if model_slugs.is_empty() {
return format!(
"\n\nNo explicit model slugs are currently available. \
Omit `{TASK_MODEL_PARAM}` to inherit the parent model."
);
}
let model_list = model_slugs
.into_iter()
.map(|slug| format!("- {slug}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"\n\nIf the user explicitly asks for the model of a subagent/task, you may ONLY use model slugs from this list:\n\
{model_list}\n\n\
If the user does not explicitly request a model, omit `{TASK_MODEL_PARAM}` to inherit the parent model."
)
}
/// Build the Task tool description with the effective subagent list.
///
/// Maps each [`SubagentEntry`] to the shared
/// [`xai_tool_types::SubagentDescriptor`] and defers to
/// [`xai_tool_types::build_task_description`] so the CLI and the prod chat
/// stack share one builder. Built-in (unshadowed) entries carry the hardcoded
/// tool-name fragment; user-defined entries carry `None` so their raw
/// `description` is used verbatim (markdown is fine — it's model-facing text).
pub(crate) fn build_task_description(
subagents: &[SubagentEntry],
model_slugs: &[String],
) -> String {
let descriptors: Vec<xai_tool_types::SubagentDescriptor> = subagents
.iter()
.map(|entry| {
let tools = match &entry.source {
SubagentSource::Builtin(b) => Some(builtin_tools_fragment(*b)),
SubagentSource::UserDefined { .. } => None,
};
xai_tool_types::SubagentDescriptor {
name: entry.name.clone(),
description: entry.description.clone(),
tools,
}
})
.collect();
let mut description = xai_tool_types::build_task_description(&descriptors, &TASK_TOOL_NAMING);
description.push_str(&task_model_guidance(model_slugs));
description
}
/// Resolve the shell name for the system prompt.
///
/// Unix: `$SHELL` env var (e.g. `/bin/zsh`).
/// Windows: detected shell from the `detect_windows_shell` cascade.
fn resolve_shell_for_prompt() -> String {
#[cfg(unix)]
{
std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into())
}
#[cfg(not(unix))]
{
xai_grok_config::shell::detect_windows_shell()
.name()
.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AgentScope;
fn entry(name: &str, desc: &str, source: SubagentSource) -> SubagentEntry {
SubagentEntry {
name: name.to_string(),
description: desc.to_string(),
source,
shadows_builtin: None,
config_source: xai_grok_tools::types::config_source::ConfigSource::Builtin,
}
}
#[test]
fn build_task_description_builtin_includes_tools() {
let subagents = vec![
entry(
"general-purpose",
"General-purpose agent.",
SubagentSource::Builtin(BuiltinAgentName::GeneralPurpose),
),
entry(
"explore",
"Explore agent.",
SubagentSource::Builtin(BuiltinAgentName::Explore),
),
];
let desc = build_task_description(&subagents, &[]);
assert!(
desc.contains(xai_tool_types::GENERAL_PURPOSE_SUBAGENT.tools_template),
"should include general-purpose tool names"
);
assert!(
desc.contains(xai_tool_types::EXPLORE_SUBAGENT.tools_template),
"should include explore tool names"
);
assert!(
desc.contains("- **general-purpose**: General-purpose agent."),
"should include agent entry"
);
}
#[test]
fn build_task_description_user_entry_is_raw() {
let subagents = vec![entry(
"code-reviewer",
"Reviews code for bugs and style issues.",
SubagentSource::UserDefined {
scope: AgentScope::Project,
},
)];
let desc = build_task_description(&subagents, &[]);
assert!(desc.contains("- **code-reviewer**: Reviews code for bugs and style issues."));
assert!(
!desc.contains("Has access to all tools:"),
"user-defined entries should not get tool fragments"
);
}
#[test]
fn build_task_description_user_template_syntax_verbatim() {
let subagents = vec![entry(
"my-agent",
"Uses ${{ some.template }} syntax.",
SubagentSource::UserDefined {
scope: AgentScope::User,
},
)];
let desc = build_task_description(&subagents, &[]);
assert!(
desc.contains("${{ some.template }}"),
"template-like syntax in user descriptions should be rendered verbatim"
);
}
#[test]
fn build_task_description_shadowed_builtin_uses_user_desc() {
let subagents = vec![SubagentEntry {
name: "explore".to_string(),
description: "My custom explore agent.".to_string(),
source: SubagentSource::UserDefined {
scope: AgentScope::Project,
},
shadows_builtin: Some(BuiltinAgentName::Explore),
config_source: xai_grok_tools::types::config_source::ConfigSource::Project {
path: std::path::PathBuf::new(),
},
}];
let desc = build_task_description(&subagents, &[]);
assert!(
desc.contains("- **explore**: My custom explore agent."),
"shadowed built-in should use user description"
);
assert!(
!desc.contains(xai_tool_types::EXPLORE_SUBAGENT.tools_template),
"shadowed built-in should NOT include built-in tool fragment"
);
}
#[test]
fn build_task_description_contains_header_and_footer() {
let subagents = vec![entry(
"explore",
"Explore.",
SubagentSource::Builtin(BuiltinAgentName::Explore),
)];
let desc = build_task_description(&subagents, &[]);
assert!(
desc.contains("Start a subagent that works on a task independently"),
"should contain header"
);
assert!(
desc.contains("## Usage notes"),
"should contain footer with '## Usage notes' section"
);
}
#[test]
fn build_task_description_uses_template_variables() {
let subagents = vec![entry(
"explore",
"Explore.",
SubagentSource::Builtin(BuiltinAgentName::Explore),
)];
let desc = build_task_description(&subagents, &[]);
assert!(
desc.contains("${{ tools.by_kind.task }}"),
"should use tools.by_kind.task template variable"
);
assert!(
desc.contains("${{ tools.by_kind.read }}"),
"should use tools.by_kind.read template variable"
);
assert!(
desc.contains("${{ params.task.subagent_type }}"),
"should use params.task.subagent_type template variable"
);
assert!(
desc.contains("${{ params.task.model }}"),
"should use params.task.model template variable"
);
}
#[test]
fn build_task_description_lists_public_model_slugs() {
let subagents = vec![entry(
"explore",
"Explore.",
SubagentSource::Builtin(BuiltinAgentName::Explore),
)];
let desc = build_task_description(
&subagents,
&["zeta".to_string(), "alpha".to_string(), "alpha".to_string()],
);
assert!(
desc
.contains("If the user explicitly asks for the model of a subagent/task, you may ONLY use model slugs from this list:\n\
- alpha\n\
- zeta")
);
assert!(
desc
.contains("If the user does not explicitly request a model, omit `${{ params.task.model }}` to inherit the parent model.")
);
assert!(!desc.contains("Available model slugs:"));
assert!(!desc.contains(concat!("grok", " models")));
}
#[test]
fn build_task_description_handles_empty_model_catalog() {
let subagents = vec![entry(
"explore",
"Explore.",
SubagentSource::Builtin(BuiltinAgentName::Explore),
)];
let desc = build_task_description(&subagents, &[]);
assert!(desc.contains("No explicit model slugs are currently available."));
assert!(desc.contains("Omit `${{ params.task.model }}` to inherit the parent model."));
assert!(!desc.contains(concat!("grok", " models")));
}
#[test]
fn task_model_guidance_resolves_model_param_override() {
use xai_grok_tools::types::template_renderer::TemplateRenderer;
use xai_grok_tools::types::tool::ToolKind;
let renderer = TemplateRenderer::new(
Default::default(),
std::collections::HashMap::from([(
ToolKind::Task,
std::collections::HashMap::from([("model".to_string(), "child_model".to_string())]),
)]),
);
let rendered = renderer
.render(&task_model_guidance(&["alpha".to_string()]))
.expect("model guidance should render");
assert!(rendered.contains("omit `child_model` to inherit the parent model"));
assert!(!rendered.contains("params.task.model"));
}
#[test]
fn child_task_description_is_concise() {
assert!(
CHILD_TASK_DESCRIPTION.contains("Prefer doing the work yourself"),
"child description should discourage recursive delegation"
);
assert!(
!CHILD_TASK_DESCRIPTION.contains("Agent types:"),
"child description should not list agent types"
);
assert!(
!CHILD_TASK_DESCRIPTION.contains("<example>"),
"child description should not contain examples"
);
assert!(
CHILD_TASK_DESCRIPTION.len() < 700,
"child description should be compact, got {} chars",
CHILD_TASK_DESCRIPTION.len()
);
}
#[test]
fn build_task_description_contains_resume_from_guidance() {
let subagents = vec![entry(
"general-purpose",
"GP agent.",
SubagentSource::Builtin(BuiltinAgentName::GeneralPurpose),
)];
let desc = build_task_description(&subagents, &[]);
assert!(
desc.contains("Resuming a previous agent (resume_from)"),
"should contain resume_from section header"
);
assert!(
desc.contains("resume_from"),
"should reference the resume_from parameter"
);
assert!(
desc.contains("keeps its full transcript and tool state"),
"should describe resume semantics"
);
assert!(
desc.contains("same subagent_type"),
"should state the resumed agent must match subagent_type"
);
}
async fn build_pager_agent(
profile: crate::config::AgentDefinition,
subagents_enabled: bool,
ask_user_question_enabled: bool,
) -> crate::agent::Agent {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::notification::ToolNotificationHandle;
AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(profile)
.with_subagents_enabled(subagents_enabled)
.with_ask_user_question_enabled(ask_user_question_enabled)
.build()
.await
.expect("agent should build for every pager-reachable flag combination")
}
#[tokio::test]
async fn pager_flag_combinations_satisfy_tool_invariants() {
use crate::config::AgentDefinition;
struct PagerFlagCase {
label: &'static str,
profile: fn() -> AgentDefinition,
subagents: bool,
ask_user: bool,
}
let cases: &[PagerFlagCase] = &[
PagerFlagCase {
label: "grok-build / subagents+ask_user",
profile: AgentDefinition::default_grok_build,
subagents: true,
ask_user: true,
},
PagerFlagCase {
label: "grok-build / subagents / no-ask-user",
profile: AgentDefinition::default_grok_build,
subagents: true,
ask_user: false,
},
PagerFlagCase {
label: "grok-build / no-subagents / ask_user",
profile: AgentDefinition::default_grok_build,
subagents: false,
ask_user: true,
},
PagerFlagCase {
label: "grok-build / no-subagents / no-ask-user",
profile: AgentDefinition::default_grok_build,
subagents: false,
ask_user: false,
},
PagerFlagCase {
label: "grok-build-ask-user / subagents",
profile: AgentDefinition::grok_build_ask_user,
subagents: true,
ask_user: true,
},
PagerFlagCase {
label: "grok-build-ask-user / no-subagents",
profile: AgentDefinition::grok_build_ask_user,
subagents: false,
ask_user: true,
},
PagerFlagCase {
label: "grok-build-plan",
profile: AgentDefinition::grok_build_plan,
subagents: true,
ask_user: true,
},
PagerFlagCase {
label: "grok-build-plan / no-ask-user",
profile: AgentDefinition::grok_build_plan,
subagents: true,
ask_user: false,
},
PagerFlagCase {
label: "grok-build-plan-no-subagents",
profile: AgentDefinition::grok_build_plan_no_subagents,
subagents: false,
ask_user: true,
},
PagerFlagCase {
label: "grok-build-plan-no-subagents / no-ask-user",
profile: AgentDefinition::grok_build_plan_no_subagents,
subagents: false,
ask_user: false,
},
];
for case in cases {
let PagerFlagCase {
label,
profile,
subagents,
ask_user,
} = case;
let agent = build_pager_agent(profile(), *subagents, *ask_user).await;
let defs = agent.tool_definitions().await;
let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect();
let mut counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for n in &names {
*counts.entry(*n).or_default() += 1;
}
let dupes: Vec<(&&str, &usize)> = counts.iter().filter(|(_, c)| **c > 1).collect();
assert!(
dupes.is_empty(),
"[{label}] tool names must be unique, found duplicates: {dupes:?}; full list: {names:?}"
);
let has_ask_user = names.contains(&"ask_user_question");
assert_eq!(
has_ask_user, *ask_user,
"[{label}] ask_user_question presence should match ask_user_question_enabled={ask_user}; got tools: {names:?}"
);
let has_task = names.contains(&"spawn_subagent");
assert_eq!(
has_task, *subagents,
"[{label}] spawn_subagent presence should match subagents_enabled={subagents}; got tools: {names:?}"
);
assert!(
names.contains(&"enter_plan_mode"),
"[{label}] enter_plan_mode must always be present (TUI plan-mode keybind needs it); got tools: {names:?}"
);
assert!(
names.contains(&"exit_plan_mode"),
"[{label}] exit_plan_mode must always be present (TUI plan-mode keybind needs it); got tools: {names:?}"
);
}
}
#[tokio::test]
async fn curated_empty_toolset_fails_agent_build() {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::notification::ToolNotificationHandle;
let mut profile = crate::config::AgentDefinition::default_grok_build();
profile.tool_config = Default::default();
profile.inject_default_tools = false;
let result = AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(profile)
.build()
.await;
match result {
Ok(_) => panic!("empty curated toolset must be rejected at build time"),
Err(err) => {
assert!(
matches!(err, AgentBuildError::InvalidConfig(_)),
"expected InvalidConfig, got: {err:?}"
)
}
}
}
/// The ask_user_question params merge must run after `ensure_plan_mode_tools`:
/// a profile that does NOT pre-declare the tool still gets the shell-resolved
/// timeout params on the injected instance. Fails if the merge is ever
/// hoisted above the injection.
#[tokio::test]
async fn plan_mode_injected_ask_user_question_receives_params() {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::implementations::grok_build::ask_user_question::AskUserQuestionParams;
use xai_grok_tools::notification::ToolNotificationHandle;
use xai_grok_tools::types::resources::Params;
let profile = crate::config::AgentDefinition::default_grok_build();
assert!(
!profile
.tool_config
.tools
.iter()
.any(|tc| tc.id == "GrokBuild:ask_user_question"),
"test premise: the profile must not pre-declare ask_user_question"
);
let mut params = serde_json::Map::new();
params.insert("timeout_enabled".into(), serde_json::Value::Bool(false));
params.insert("timeout_secs".into(), serde_json::Value::from(5));
let agent = AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(profile)
.with_ask_user_question_params(params)
.build()
.await
.expect("agent should build");
let applied = agent
.tool_bridge()
.read_resource::<Params<AskUserQuestionParams>>()
.await
.expect("finalize must insert Params for the injected ask_user_question");
assert_eq!(applied.0.timeout_enabled, Some(false));
assert_eq!(applied.0.timeout_secs, Some(5));
}
async fn build_with_tools(tools: Vec<String>, disallowed: Vec<String>) -> crate::agent::Agent {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::notification::ToolNotificationHandle;
let mut def = crate::config::AgentDefinition::default_grok_build();
def.tools = tools;
def.disallowed_tools = disallowed;
AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(def)
.build()
.await
.unwrap()
}
/// Build a default agent under a session allowlist + the agent's own
/// allowlist, returning the effective (short) tool names.
async fn session_clamp_tool_names(
own_tools: Vec<String>,
session_allow: Vec<String>,
) -> Vec<String> {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::notification::ToolNotificationHandle;
let mut def = crate::config::AgentDefinition::default_grok_build();
def.tools = own_tools;
def.session_tools_allowlist = Some(session_allow);
let agent = AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(def)
.build()
.await
.unwrap();
agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect()
}
/// The session clamp and the agent's own allowlist both bind the effective
/// toolset (intersection), and a disjoint pair yields deny-all — never the
/// "empty list == inherit all" collapse.
#[tokio::test]
async fn session_clamp_intersects_own_allowlist() {
let has = |v: &[String], t: &str| v.iter().any(|n| n == t);
let names = session_clamp_tool_names(vec![], vec!["read_file".into(), "grep".into()]).await;
assert!(has(&names, "read_file") && has(&names, "grep"), "{names:?}");
assert!(!has(&names, "run_terminal_cmd"), "{names:?}");
let names = session_clamp_tool_names(
vec!["read_file".into(), "search_replace".into()],
vec!["read_file".into(), "grep".into()],
)
.await;
assert!(has(&names, "read_file"), "{names:?}");
assert!(
!has(&names, "search_replace"),
"session denies it: {names:?}"
);
assert!(!has(&names, "grep"), "own allowlist denies it: {names:?}");
let names = session_clamp_tool_names(
vec!["search_replace".into()],
vec!["read_file".into(), "grep".into()],
)
.await;
assert!(
!has(&names, "search_replace"),
"session denies it: {names:?}"
);
assert!(
!has(&names, "read_file"),
"own allowlist denies it: {names:?}"
);
assert!(
!has(&names, "run_terminal_cmd"),
"must not inherit-all: {names:?}"
);
}
/// An unresolved own-allowlist entry makes step 4 fall back to the full
/// toolset; the session clamp (step 4b) must still bind afterward.
#[tokio::test]
async fn session_clamp_binds_when_own_allowlist_falls_back() {
let names = session_clamp_tool_names(
vec!["read_file".into(), "bogus_unresolved_xyz".into()],
vec!["read_file".into()],
)
.await;
assert!(names.iter().any(|n| n == "read_file"), "{names:?}");
assert!(
!names.iter().any(|n| n == "run_terminal_cmd"),
"session clamp must bind despite the step-4 full-toolset fallback: {names:?}"
);
}
#[test]
fn session_tools_allowed_clamp() {
let mut def = crate::config::AgentDefinition::general_purpose();
assert!(def.session_tools_allowed("read_file"));
def.session_tools_allowlist = Some(vec!["read_file".into()]);
assert!(def.session_tools_allowed("GrokBuild:read_file"));
assert!(!def.session_tools_allowed("grep"));
def.session_tools_denylist = Some(vec!["read_file".into()]);
assert!(!def.session_tools_allowed("read_file"));
}
#[test]
fn hosted_tool_gating() {
let base = crate::config::AgentDefinition::general_purpose;
assert!(base().hosted_tool_allowed("web_search"));
assert!(base().hosted_tool_allowed("x_search"));
let mut d = base();
d.disallowed_tools = vec!["x_search".into()];
assert!(!d.hosted_tool_allowed("x_search"));
assert!(d.hosted_tool_allowed("web_search"));
let mut d = base();
d.tools = vec!["read_file".into()];
assert!(!d.hosted_tool_allowed("web_search"));
assert!(!d.hosted_tool_allowed("x_search"));
let mut d = base();
d.session_tools_allowlist = Some(vec!["read_file".into()]);
assert!(!d.hosted_tool_allowed("web_search"));
}
const AGENT_TOOLS_BASE: &[&str] = &["read_file", "run_terminal_cmd"];
#[tokio::test]
async fn agent_type_restricted_to_listed_types() {
let mut tools: Vec<String> = AGENT_TOOLS_BASE.iter().map(|s| s.to_string()).collect();
tools.push("Agent(worker, researcher)".into());
let agent = build_with_tools(tools, vec![]).await;
assert_eq!(
agent.definition().allowed_subagent_types,
Some(vec!["worker".into(), "researcher".into()])
);
let names: Vec<_> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
assert!(names.contains(&"read_file".to_string()));
}
#[tokio::test]
async fn bare_agent_allows_all_spawns() {
let mut tools: Vec<String> = AGENT_TOOLS_BASE.iter().map(|s| s.to_string()).collect();
tools.push("Agent".into());
let agent = build_with_tools(tools, vec![]).await;
assert_eq!(agent.definition().allowed_subagent_types, None);
}
#[tokio::test]
async fn spawning_blocked_or_unrestricted() {
let mut tools: Vec<String> = AGENT_TOOLS_BASE.iter().map(|s| s.to_string()).collect();
tools.push("grep".into());
let agent = build_with_tools(tools, vec![]).await;
assert_eq!(agent.definition().allowed_subagent_types, Some(vec![]));
let agent = build_with_tools(vec![], vec![]).await;
assert_eq!(agent.definition().allowed_subagent_types, None);
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::notification::ToolNotificationHandle;
let mut def = crate::config::AgentDefinition::default_grok_build();
def.disallowed_tools = vec!["Agent".into()];
let agent = AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(def)
.build()
.await
.unwrap();
assert_eq!(agent.definition().allowed_subagent_types, Some(vec![]));
}
#[tokio::test]
async fn spawning_blocked_disables_all_background_bash_modes() {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::implementations::grok_build::bash::BashParams;
use xai_grok_tools::notification::ToolNotificationHandle;
use xai_grok_tools::types::resources::Params;
let mut definition = crate::config::AgentDefinition::default_grok_build();
definition.tools = vec!["run_terminal_cmd".into()];
let bash_params = serde_json::json!(
{ "max_timeout_secs" : 36_000.0, "auto_background_on_timeout" : true,
"allow_background_operator" : false, }
)
.as_object()
.unwrap()
.clone();
let agent = AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(definition)
.with_bash_params(bash_params)
.build()
.await
.expect("spawning-blocked agent should normalize background bash params");
assert_eq!(agent.definition().allowed_subagent_types, Some(vec![]));
let applied = agent
.tool_bridge()
.read_resource::<Params<BashParams>>()
.await
.expect("bash params should be registered");
assert!(!applied.0.enabled_background);
assert!(!applied.0.auto_background_on_timeout);
assert_eq!(applied.0.max_timeout_secs, Some(36_000.0));
assert!(!applied.0.allow_background_operator);
}
#[tokio::test]
async fn disallowed_agent_type_strips_from_allowed() {
let mut tools: Vec<String> = AGENT_TOOLS_BASE.iter().map(|s| s.to_string()).collect();
tools.push("Agent(worker, researcher)".into());
let agent = build_with_tools(tools, vec!["Agent(researcher)".into()]).await;
assert_eq!(
agent.definition().allowed_subagent_types,
Some(vec!["worker".into()])
);
}
/// Compat allowlist names (`Read`, `Bash`, `Grep`) map to their Grok
/// equivalents by `ToolKind` — a real restricted toolset, not zero tools.
#[tokio::test]
async fn claude_tool_names_map_to_grok_equivalents() {
let tools = vec!["Read".into(), "Bash".into(), "Grep".into()];
let agent = build_with_tools(tools, vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
assert!(
names.contains(&"read_file".to_string()),
"Read→read_file; got: {names:?}"
);
assert!(
names.contains(&"run_terminal_command".to_string()),
"Bash→run_terminal_command; got: {names:?}"
);
assert!(
names.contains(&"grep".to_string()),
"Grep→grep; got: {names:?}"
);
assert!(
!names.contains(&"search_replace".to_string()),
"Edit must be excluded by the allowlist; got: {names:?}"
);
}
/// Shell, LSP, ask, and task-lifecycle tool names resolve to their grok
/// `ToolKind`, so those allowlists are honored instead of failing open.
#[test]
fn shell_lsp_ask_and_task_tool_names_map() {
assert_eq!(claude_tool_kind("PowerShell"), Some(ToolKind::Execute));
assert_eq!(claude_tool_kind("LSP"), Some(ToolKind::Lsp));
assert_eq!(claude_tool_kind("AskUserQuestion"), Some(ToolKind::AskUser));
for name in ["TaskOutput", "BashOutputTool", "AgentOutputTool"] {
assert_eq!(claude_tool_kind(name), Some(ToolKind::BackgroundTaskAction));
}
assert_eq!(claude_tool_kind("TaskStop"), Some(ToolKind::KillTaskAction));
assert_eq!(claude_tool_kind("EnterPlanMode"), None);
assert_eq!(claude_tool_kind("ExitPlanMode"), None);
}
/// `[Read, Edit, AskUserQuestion]` builds end-to-end: the allowlist is
/// honored (no full-toolset fallback) and `ask_user_question` stands
/// alone after the injected plan-mode tools are dropped.
#[tokio::test]
async fn ask_user_question_allowlist_builds_without_plan_tools() {
let tools = vec!["Read".into(), "Edit".into(), "AskUserQuestion".into()];
let agent = build_with_tools(tools, vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
for kept in ["read_file", "search_replace", "ask_user_question"] {
assert!(names.contains(&kept.to_string()), "got: {names:?}");
}
for dropped in ["enter_plan_mode", "exit_plan_mode", "run_terminal_command"] {
assert!(!names.contains(&dropped.to_string()), "got: {names:?}");
}
}
/// Entries we can't match or map (a typo, a renamed/absent tool) fall back
/// to the full toolset rather than crippling the agent.
#[tokio::test]
async fn unmappable_allowlist_falls_back_to_full_toolset() {
let tools = vec!["Frobnicate".into(), "Wibble".into()];
let agent = build_with_tools(tools, vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
assert!(names.contains(&"read_file".to_string()), "got: {names:?}");
assert!(
names.contains(&"search_replace".to_string()),
"got: {names:?}"
);
assert!(
names.contains(&"run_terminal_command".to_string()),
"got: {names:?}"
);
}
/// End-to-end: an on-disk plugin agent parsed via `from_file_frontmatter_only`
/// with a compat-style `tools:` allowlist gets the mapped toolset (not 0 tools).
#[tokio::test]
async fn plugin_style_agent_file_maps_claude_tools() {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::notification::ToolNotificationHandle;
const MD: &str = "---\n\
name: test\n\
description: test agent\n\
tools: Read, Bash, Grep\n\
---\n\n\
Test agent body.\n";
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.md");
std::fs::write(&path, MD).unwrap();
let def = crate::config::AgentDefinition::from_file_frontmatter_only(&path).unwrap();
assert_eq!(
def.tools,
vec!["Read".to_string(), "Bash".to_string(), "Grep".to_string()],
);
let agent = AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(def)
.build()
.await
.unwrap();
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
assert!(
names.contains(&"read_file".to_string()),
"Read→read_file; got: {names:?}"
);
assert!(
names.contains(&"run_terminal_command".to_string()),
"Bash→run_terminal_command; got: {names:?}"
);
assert!(
names.contains(&"grep".to_string()),
"Grep→grep; got: {names:?}"
);
assert!(
!names.contains(&"search_replace".to_string()),
"Edit must be excluded; got: {names:?}"
);
}
/// A restrictive allowlist must never strip MCP access. Compat allowlists
/// treat `mcp__*` as always-on, so grok keeps the MCP meta-tools
/// (`search_tool` / `use_tool`) regardless of what the allowlist names.
#[tokio::test]
async fn restrictive_allowlist_keeps_mcp_access() {
let agent = build_with_tools(vec!["Read".into()], vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
assert!(
names.contains(&"read_file".to_string()),
"Read→read_file; got: {names:?}"
);
assert!(
names.contains(&"search_tool".to_string()) && names.contains(&"use_tool".to_string()),
"search_tool/use_tool (MCP access) must not be stripped; got: {names:?}"
);
assert!(
!names.contains(&"search_replace".to_string()),
"Edit must be excluded; got: {names:?}"
);
}
#[tokio::test]
async fn registered_but_absent_web_tools_do_not_fall_back() {
let tools = vec![
"read_file".into(),
"grep".into(),
"list_dir".into(),
"web_search".into(),
"web_fetch".into(),
];
let agent = build_with_tools(tools, vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
for absent in ["web_search", "web_fetch"] {
assert!(!names.contains(&absent.to_string()), "got: {names:?}");
}
for kept in ["read_file", "grep", "list_dir"] {
assert!(names.contains(&kept.to_string()), "got: {names:?}");
}
for excluded in ["run_terminal_command", "search_replace"] {
assert!(!names.contains(&excluded.to_string()), "got: {names:?}");
}
}
#[tokio::test]
async fn requested_enabled_web_tools_survive_allowlist() {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::implementations::grok_build::web_fetch::WebFetchConfig;
use xai_grok_tools::implementations::web_search::WebSearchConfig;
use xai_grok_tools::notification::ToolNotificationHandle;
let mut definition = crate::config::AgentDefinition::default_grok_build();
definition.tools = vec![
"read_file".into(),
"grep".into(),
"list_dir".into(),
"web_search".into(),
"web_fetch".into(),
];
let agent = AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(definition)
.with_web_search_config(WebSearchConfig::Enabled {
api_key: "test-key".into(),
base_url: "https://api.x.ai/v1".into(),
model: "test-web-search-model".into(),
extra_headers: Default::default(),
alpha_test_key: None,
})
.with_web_fetch_config(WebFetchConfig::Enabled {
params: Default::default(),
})
.build()
.await
.expect("agent should build with requested web tools");
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
for kept in ["read_file", "grep", "list_dir", "web_search", "web_fetch"] {
assert!(names.contains(&kept.to_string()), "got: {names:?}");
}
for excluded in ["run_terminal_command", "search_replace"] {
assert!(!names.contains(&excluded.to_string()), "got: {names:?}");
}
}
/// grok-build toolsets have no Skill tool — skills are read from
/// `SKILL.md` via `read_file` — so a compat `Skill` allowlist entry grants
/// toolset.
#[tokio::test]
async fn skill_allowlist_maps_to_read() {
let agent = build_with_tools(vec!["Skill".into()], vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
assert!(
names.contains(&"read_file".to_string()),
"Skill→read_file; got: {names:?}"
);
assert!(
names.contains(&"search_tool".to_string()) && names.contains(&"use_tool".to_string()),
"MCP access must be kept; got: {names:?}"
);
assert!(
!names.contains(&"search_replace".to_string())
&& !names.contains(&"run_terminal_command".to_string()),
"no full-toolset fallback — unlisted tools must be excluded; got: {names:?}"
);
}
/// `Read` and `Skill` both map to `ToolKind::Read`, but the allowlist phase
/// `read_file` (plus always-on MCP access) without falling back to the full
/// single base `read_file` entry is kept exactly once, never double-registered.
#[tokio::test]
async fn read_and_skill_allowlist_keeps_single_read_file() {
let agent = build_with_tools(vec!["Read".into(), "Skill".into()], vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
let read_file_count = names.iter().filter(|n| *n == "read_file").count();
assert_eq!(
read_file_count, 1,
"read_file must be registered exactly once for tools: [Read, Skill]; got: {names:?}"
);
}
/// A compat-style `mcp__server__tool` allowlist entry is always allowed: it
/// neither triggers the full-toolset fallback nor strips MCP access.
#[tokio::test]
async fn mcp_prefixed_allowlist_entry_keeps_mcp_access() {
let tools = vec!["mcp__github__create_issue".into(), "Read".into()];
let agent = build_with_tools(tools, vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
assert!(
names.contains(&"read_file".to_string()),
"Read must be kept; got: {names:?}"
);
assert!(
names.contains(&"search_tool".to_string()) && names.contains(&"use_tool".to_string()),
"MCP access must be kept; got: {names:?}"
);
assert!(
!names.contains(&"search_replace".to_string())
&& !names.contains(&"run_terminal_command".to_string()),
"no full-toolset fallback — unlisted tools must be excluded; got: {names:?}"
);
}
/// Compat `ToolSearch` meta-tool maps to grok's `search_tool` (MCP
/// is a filter (`retain`) over a `HashSet` of kinds, not an inserter — so the
/// falling back to the full toolset.
#[tokio::test]
async fn tool_search_allowlist_maps_to_search_tool() {
let agent = build_with_tools(vec!["ToolSearch".into()], vec![]).await;
let names: Vec<String> = agent
.tool_definitions()
.await
.iter()
.map(|d| d.function.name.clone())
.collect();
assert!(
names.contains(&"search_tool".to_string()),
"ToolSearch→search_tool; got: {names:?}"
);
assert!(
!names.contains(&"search_replace".to_string()),
"no full-toolset fallback — Edit must be excluded; got: {names:?}"
);
}
async fn build_with_web_search(
web_search_enabled: bool,
backend_search_enabled: bool,
disallowed_tools: &[&str],
) -> crate::agent::Agent {
use xai_grok_tools::computer::local::LocalTerminalBackend;
use xai_grok_tools::implementations::web_search::WebSearchConfig;
use xai_grok_tools::notification::ToolNotificationHandle;
let web_search_config = if web_search_enabled {
WebSearchConfig::Enabled {
api_key: "test-key".into(),
base_url: "https://api.x.ai/v1".into(),
model: "test-web-search-model".into(),
extra_headers: Default::default(),
alpha_test_key: None,
}
} else {
WebSearchConfig::Disabled
};
let mut def = crate::config::AgentDefinition::default_grok_build();
def.disallowed_tools = disallowed_tools.iter().map(|s| s.to_string()).collect();
AgentBuilder::new(
std::env::temp_dir(),
Arc::new(LocalTerminalBackend::new()),
ToolNotificationHandle::noop(),
)
.from_definition(def)
.with_web_search_config(web_search_config)
.with_backend_search(backend_search_enabled)
.build()
.await
.expect("agent should build for backend-search test case")
}
#[tokio::test]
async fn disallowed_web_search_strips_function_and_hosted_tools() {
let agent = build_with_web_search(true, true, &["web_search"]).await;
let hosted = agent.hosted_tools();
assert!(
!hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::WebSearch { .. })),
"hosted WebSearch must be removed when web_search is disallowed, got: {hosted:?}"
);
assert!(
hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch)),
"XSearch must remain when only web_search is disallowed, got: {hosted:?}"
);
let has_web_search_fn = agent
.tool_definitions()
.await
.iter()
.any(|td| short_tool_name(&td.function.name) == "web_search");
assert!(
!has_web_search_fn,
"function web_search tool must be removed when disallowed"
);
}
/// Regression: with backend search + web search both enabled, both
/// hosted tools appear and `backend_search_enabled()` is true.
#[tokio::test]
async fn hosted_tools_populated_when_backend_search_and_web_search_enabled() {
let agent = build_with_web_search(true, true, &[]).await;
assert!(agent.backend_search_enabled());
let hosted = agent.hosted_tools();
assert!(
hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::WebSearch { .. })),
"expected WebSearch hosted tool, got: {hosted:?}"
);
assert!(
hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch)),
"expected XSearch hosted tool, got: {hosted:?}"
);
}
/// XSearch is added unconditionally when backend search is on;
/// WebSearch requires the web-search config.
#[tokio::test]
async fn hosted_tools_only_xsearch_when_web_search_disabled() {
let agent = build_with_web_search(false, true, &[]).await;
let hosted = agent.hosted_tools();
assert!(
!hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::WebSearch { .. })),
"WebSearch must NOT appear when web_search is disabled, got: {hosted:?}"
);
assert!(
hosted
.iter()
.any(|t| matches!(t, xai_grok_sampling_types::HostedTool::XSearch)),
"expected XSearch hosted tool, got: {hosted:?}"
);
}
/// Backend search off: gate bool false and no hosted tools, regardless
/// of web-search config.
#[tokio::test]
async fn hosted_tools_empty_when_backend_search_disabled() {
let agent = build_with_web_search(true, false, &[]).await;
assert!(!agent.backend_search_enabled());
assert!(agent.hosted_tools().is_empty());
}
}