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,257 @@
//! Validation of [`ToolConfigEntry`](crate::ToolConfigEntry) fields,
//! shared so the backend's save-time check cannot drift from what the
//! tools server enforces at finalize/bind. Errors carry the offending input
//! so callers can render gRPC violations without re-parsing.
use serde_json::{Map, Value};
use xai_tool_protocol::ToolId;
/// Why a [`ToolConfigEntry`](crate::ToolConfigEntry) is invalid.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolConfigEntryErrorKind {
/// Not valid JSON. Includes an explicitly-set empty string: proto3
/// `optional` tracks presence, so `Some("")` is rejected, not unset.
ParamsJsonParse { error: String, raw: String },
/// Valid JSON but not an object.
ParamsJsonNotObject { value: Value },
/// `name_override` is not a valid `ToolId` (charset/length contract).
NameOverrideInvalid { name: String, error: String },
}
/// Validation error for one entry in a tool-config list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolConfigEntryError {
pub index: usize,
pub tool_id: String,
pub kind: ToolConfigEntryErrorKind,
}
impl ToolConfigEntryError {
/// Request field path of the failing field, e.g. `tools[3].params_json`.
pub fn field_path(&self) -> String {
match self.kind {
ToolConfigEntryErrorKind::ParamsJsonParse { .. }
| ToolConfigEntryErrorKind::ParamsJsonNotObject { .. } => {
format!("tools[{}].params_json", self.index)
}
ToolConfigEntryErrorKind::NameOverrideInvalid { .. } => {
format!("tools[{}].name_override", self.index)
}
}
}
}
impl std::fmt::Display for ToolConfigEntryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.kind {
ToolConfigEntryErrorKind::ParamsJsonParse { error, .. } => write!(
f,
"{}: {} failed to parse JSON: {error}",
self.tool_id,
self.field_path()
),
ToolConfigEntryErrorKind::ParamsJsonNotObject { .. } => write!(
f,
"{}: {} must be a JSON object",
self.tool_id,
self.field_path()
),
ToolConfigEntryErrorKind::NameOverrideInvalid { name, error } => write!(
f,
"{}: {} is not a valid tool name ({name:?}): {error}",
self.tool_id,
self.field_path()
),
}
}
}
impl std::error::Error for ToolConfigEntryError {}
/// Parse and validate a `params_json`, returning the decoded object (or
/// `None` when unset). `index`/`tool_id` are only used for error reporting.
pub fn parse_params_json(
index: usize,
tool_id: &str,
params_json: Option<&str>,
) -> Result<Option<Map<String, Value>>, ToolConfigEntryError> {
let Some(raw) = params_json else {
return Ok(None);
};
let value: Value = serde_json::from_str(raw).map_err(|err| ToolConfigEntryError {
index,
tool_id: tool_id.to_owned(),
kind: ToolConfigEntryErrorKind::ParamsJsonParse {
error: err.to_string(),
raw: raw.to_owned(),
},
})?;
match value {
Value::Object(object) => Ok(Some(object)),
other => Err(ToolConfigEntryError {
index,
tool_id: tool_id.to_owned(),
kind: ToolConfigEntryErrorKind::ParamsJsonNotObject { value: other },
}),
}
}
/// Validates a `name_override` against the `ToolId` charset/length contract,
/// mirroring [`parse_params_json`] as the shared source of truth.
pub fn validate_name_override(
index: usize,
tool_id: &str,
name_override: Option<&str>,
) -> Result<(), ToolConfigEntryError> {
let Some(name) = name_override else {
return Ok(());
};
ToolId::new(name).map_err(|err| ToolConfigEntryError {
index,
tool_id: tool_id.to_owned(),
kind: ToolConfigEntryErrorKind::NameOverrideInvalid {
name: name.to_owned(),
error: err.to_string(),
},
})?;
Ok(())
}
/// Returns the first entry whose `id` is not in `allowed_ids`, as
/// `(index, id)`, or `None` when all ids are allowed.
///
/// Pure so backend save-time validation and any future consumer share one rule.
pub fn first_unknown_tool_id<'a>(
entries: &'a [crate::ToolConfigEntry],
allowed_ids: &std::collections::HashSet<String>,
) -> Option<(usize, &'a str)> {
entries
.iter()
.enumerate()
.find(|(_, entry)| !allowed_ids.contains(&entry.id))
.map(|(index, entry)| (index, entry.id.as_str()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unset_params_is_ok_none() {
assert_eq!(parse_params_json(0, "GrokBuild:grep", None), Ok(None));
}
#[test]
fn valid_object_is_returned() {
let parsed = parse_params_json(0, "GrokBuild:grep", Some(r#"{"max_results":50}"#)).unwrap();
assert_eq!(
parsed,
Some(
serde_json::json!({"max_results": 50})
.as_object()
.unwrap()
.clone()
)
);
}
#[test]
fn empty_string_is_a_parse_error() {
let err = parse_params_json(3, "GrokBuild:grep", Some("")).unwrap_err();
assert_eq!(err.index, 3);
assert_eq!(err.field_path(), "tools[3].params_json");
assert!(matches!(
err.kind,
ToolConfigEntryErrorKind::ParamsJsonParse { .. }
));
}
#[test]
fn invalid_json_is_a_parse_error() {
let err = parse_params_json(0, "t", Some("{not json")).unwrap_err();
assert!(matches!(
err.kind,
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. } if raw == "{not json"
));
}
#[test]
fn non_object_json_is_rejected() {
let err = parse_params_json(1, "t", Some("[1,2,3]")).unwrap_err();
assert!(matches!(
err.kind,
ToolConfigEntryErrorKind::ParamsJsonNotObject {
value: Value::Array(_)
}
));
}
#[test]
fn name_override_unset_or_valid_is_ok() {
assert_eq!(validate_name_override(0, "GrokBuild:grep", None), Ok(()));
for name in ["search", "GrokBuild:grep", "a-b_C9"] {
assert_eq!(
validate_name_override(0, "GrokBuild:grep", Some(name)),
Ok(()),
"name={name:?}"
);
}
}
#[test]
fn name_override_outside_charset_is_rejected() {
for name in ["has space", "", "a:b:c", "dot.name"] {
let err = validate_name_override(2, "GrokBuild:grep", Some(name)).unwrap_err();
assert_eq!(err.index, 2, "name={name:?}");
assert_eq!(err.field_path(), "tools[2].name_override");
assert!(
matches!(
&err.kind,
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. } if n == name
),
"name={name:?} kind={:?}",
err.kind
);
}
}
fn entry(id: &str) -> crate::ToolConfigEntry {
crate::ToolConfigEntry {
id: id.to_owned(),
..Default::default()
}
}
fn allowed(ids: &[&str]) -> std::collections::HashSet<String> {
ids.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn all_ids_present_returns_none() {
let entries = [entry("GrokBuild:grep"), entry("GrokBuild:read_file")];
let allowed = allowed(&["GrokBuild:grep", "GrokBuild:read_file", "GrokBuild:bash"]);
assert_eq!(first_unknown_tool_id(&entries, &allowed), None);
}
#[test]
fn empty_entries_returns_none() {
assert_eq!(
first_unknown_tool_id(&[], &allowed(&["GrokBuild:grep"])),
None
);
}
#[test]
fn first_unknown_id_is_returned_with_index() {
let entries = [
entry("GrokBuild:grep"),
entry("GrokBuild:nonexistent"),
entry("GrokBuild:also_missing"),
];
let allowed = allowed(&["GrokBuild:grep"]);
assert_eq!(
first_unknown_tool_id(&entries, &allowed),
Some((1, "GrokBuild:nonexistent"))
);
}
}

View file

@ -0,0 +1,139 @@
//! Shared API definitions for Grok tools: protobuf types, config validation,
//! and canonical slash-command wording.
//!
//! Used by both the tools library and the gRPC server, and by host services
//! that must not depend on the tools implementation crate.
#![allow(clippy::derive_partial_eq_without_eq)]
/// Generated protobuf types.
pub mod pb {
include!(concat!(env!("OUT_DIR"), "/xai.grok.tools.v1.rs"));
}
pub mod config_validation;
pub mod slash_commands;
// Re-export commonly used types at the crate root for convenience
pub use pb::{
// Agent types
AgentCompletionRequirement,
AgentToolExecConfig,
AgentToolRetryConfig,
// Request/response types
ClearToolOverrideRequest,
ClearToolOverrideResponse,
DisableToolRequest,
DisableToolResponse,
EnableToolRequest,
EnableToolResponse,
// Enums
ErrorCode,
ExecuteToolRequest,
ExecuteToolResponse,
ExecutionMetadata,
ExecutionOptions,
FinalizeAgentRequest,
FinalizeAgentResponse,
FinalizeConfigValidationDetails,
FinalizeConfigViolation,
// Tool server config (finalize-time)
FinalizeToolServerConfigRequest,
FinalizeToolServerConfigResponse,
GetAgentInfoRequest,
GetAgentInfoResponse,
GetCompletionStateRequest,
GetCompletionStateResponse,
GetSystemPromptRequest,
GetSystemPromptResponse,
GetSystemRemindersRequest,
GetSystemRemindersResponse,
GetToolInfoRequest,
GetToolOptionsRequest,
GetToolOptionsResponse,
// Tool state
GetToolStateRequest,
GetToolStateResponse,
// Truncation config
GetTruncationConfigRequest,
GetTruncationConfigResponse,
ListToolsRequest,
ListToolsResponse,
// Output format specs
OutputFieldSpec,
OutputFormat,
OutputFormatSpec,
ResetCompletionStateRequest,
ResetCompletionStateResponse,
ResetToolOptionsRequest,
ResetToolOptionsResponse,
SetSystemRemindersRequest,
SetSystemRemindersResponse,
SetToolOptionsRequest,
SetToolOptionsResponse,
SetToolOverrideRequest,
SetToolOverrideResponse,
SetTruncationConfigRequest,
SetTruncationConfigResponse,
// Streaming types
StreamDataChunk,
StreamDataKind,
StreamFinalResult,
// Capability/metadata types
ToolCapabilities,
ToolCategory,
// Per-tool config entry
ToolConfigEntry,
ToolError,
ToolInfo,
ToolSource,
ToolStreamChunk,
ToolSuccess,
TruncationConfig,
// Version lifecycle warnings
VersionWarning,
};
/// Default client-facing tool name derived from a namespaced tool id.
///
/// Tool ids are colon-separated `Namespace:tool` (e.g. `GrokBuild:grep`); the
/// default name is the segment after the FIRST colon, so an id with embedded
/// colons (`ns:a:b`) resolves to `a`. Ids without a colon are returned as-is.
///
/// This is the single source of truth shared by the tools server (which
/// advertises tools under this name unless `name_override` is set) and any
/// client that needs to predict the advertised name from a config entry
/// (e.g. prompt tool selection in a downstream service). Keeping both sides on
/// this helper prevents a silent desync that would drop tools from prompts.
pub fn default_client_name(id: &str) -> &str {
id.split(':').nth(1).unwrap_or(id)
}
/// Convert ToolCategory enum to a string representation.
impl ToolCategory {
/// Get the string representation of the category.
pub fn as_str(&self) -> &'static str {
match self {
Self::Unspecified => "unspecified",
Self::File => "file",
Self::Search => "search",
Self::Shell => "shell",
Self::Workflow => "workflow",
Self::External => "external",
Self::Custom => "custom",
}
}
}
#[cfg(test)]
mod default_client_name_tests {
use super::default_client_name;
#[test]
fn pins_first_colon_derivation() {
assert_eq!(default_client_name("GrokBuild:grep"), "grep");
assert_eq!(default_client_name("ns:a:b"), "a");
assert_eq!(default_client_name("bare"), "bare");
assert_eq!(default_client_name(""), "");
}
}

View file

@ -0,0 +1,217 @@
//! Canonical slash-command wording (`/loop`, `/imagine`, `/imagine-video`, `/goal`),
//! shared by every front-end (Grok Build shell/pager and other hosts) so
//! expansions cannot drift.
/// Canonical tool name advertised by the scheduler create tool. Gating code
/// (shell `CommandAvailability`, pager `required_tools`, host command lists)
/// keys `/loop` availability on this name.
pub const SCHEDULER_CREATE_TOOL_NAME: &str = "scheduler_create";
/// Usage hint shown when `/loop` is invoked with no arguments.
pub fn loop_usage_message() -> &'static str {
"Usage: /loop [interval] <prompt>\n\
Example: /loop 30m check deploy status\n\
Example: /loop check deploy status every hour\n\n\
Tell me how often it should run (e.g. 30m, 1 hour, every 2 days)."
}
/// Build the model instruction that `/loop` expands into for `args`.
///
/// The model, not brittle host parsing, turns the request into the
/// `scheduler_create` interval, accepting every natural phrasing and erroring
/// on bad input rather than silently defaulting. See [`loop_usage_message`].
pub fn loop_schedule_instruction(args: &str) -> String {
format!(
"# /loop -- schedule a recurring prompt\n\n\
Parse the input below into an interval and a prompt, then schedule it with scheduler_create.\n\n\
## Deriving the interval\n\
Read how often to run from the user's request however they phrase it and convert it\n\
to a compact `<number><unit>` string, where unit is one of `s` (seconds), `m` (minutes),\n\
`h` (hours), or `d` (days). The interval may appear at the start or end of the request;\n\
extract it and use the remaining text as the prompt.\n\n\
The minimum interval is 60 seconds; shorter values are raised to 60s, so tell the user if that applies.\n\n\
If the request contains no interval at all, ask the user how often it should run before\n\
scheduling. Do NOT invent or assume a default interval.\n\n\
## Action\n\
1. Call scheduler_create with: interval (the compact string you derived), prompt,\n\
recurring: true, fire_immediately: true. If the interval is unparseable, the tool\n\
returns an error fix the interval string rather than guessing.\n\
2. Confirm: what's scheduled, the cadence, that it auto-expires after 7 days,\n\
and that they can cancel with scheduler_delete (include the job ID).\n\
3. Do NOT execute the prompt inline. The scheduler will fire it immediately.\n\n\
## Input\n\
{args}"
)
}
/// Canonical name of the image generation tool; gates `/imagine`.
pub const IMAGE_GEN_TOOL_NAME: &str = "image_gen";
/// Advertised name of the /imagine command.
pub const IMAGINE_COMMAND_NAME: &str = "imagine";
/// Canonical name of the image-to-video tool; gates `/imagine-video`.
pub const IMAGE_TO_VIDEO_TOOL_NAME: &str = "image_to_video";
/// Advertised name of the /imagine-video command.
pub const IMAGINE_VIDEO_COMMAND_NAME: &str = "imagine-video";
/// Usage hint shown when `/imagine` is invoked with no arguments.
pub fn imagine_usage_message() -> &'static str {
"Usage: /imagine <description>\n\
Provide a text description to generate an image."
}
/// Build the model instruction that `/imagine` expands into for `prompt`.
pub fn imagine_instruction(prompt: &str) -> String {
format!(
"Call the image_gen tool immediately, passing the user's prompt below \
verbatim do not rewrite, embellish, or expand it. \
After the tool completes, briefly acknowledge and mention \
where the image was saved.\n\n\
Prompt: {prompt}"
)
}
/// Usage hint shown when `/imagine-video` is invoked with no arguments.
pub fn imagine_video_usage_message() -> &'static str {
"Usage: /imagine-video <description>\n\
Provide a text description to generate a video."
}
/// Build the model instruction that `/imagine-video` expands into for `prompt`.
pub fn imagine_video_instruction(prompt: &str) -> String {
format!(
"{IMAGINE_VIDEO_SKILL}\n\n\
User prompt: {prompt}"
)
}
/// Video workflow guidance injected by `/imagine-video`.
const IMAGINE_VIDEO_SKILL: &str = "\
# Imagine Video
Video starts from an image there is no text-to-video tool. \
Default to `image_to_video`; use `reference_to_video` only when the user \
explicitly asks for it or a shot genuinely needs multiple reference images.
## Default: single clip
Unless the user asks for a long video, multiple scenes, or a multi-shot sequence, \
generate **one** video:
1. Create a source image with `image_gen` that stages the first frame \
(composition, subject, lighting).
2. Call `image_to_video` with that image and a short prompt describing the motion \
or camera move (12 sentences, present tense).
3. After the tool completes, mention the saved file path so the user can find it.
## Longer / multi-shot videos
When the user requests a longer video, multiple scenes, or a narrative sequence:
1. **Plan the story as shots** break the idea into distinct shots, one beat each.
2. **Favor frequent, short shots** prefer more 6s clips over fewer long ones; more cuts keep it dynamic.
3. **Create each shot's source image** with `image_gen` (or `image_edit` to combine references), keeping characters and settings consistent across shots.
4. **Animate each shot with `image_to_video`** the source image becomes frame 1.
5. **Assemble with FFmpeg** using stream copy (`ffmpeg -f concat ... -c copy` never re-encode). \
Keep every shot at the same resolution and frame rate so the concat works. \
After assembly, mention the final output path.
## Shot guidance
- **Prompt-craft:** one short, vivid moment in present tense with a clear camera movement, in 12 sentences.
- **Minimal but interesting:** one clear subject, one simple motion or camera move per shot. Avoid complex multi-action animation; make the shot compelling through composition, lighting, and a strong moment.
- **Complex source image?** Intricate frames (busy geometry, fine detail, heavy reflections) warp when animated. Keep the subject fixed and move only the camera (slow push-in, orbit, or parallax), or break into simpler shots. For new shots, generate a simpler, animation-friendly base image rather than animating a busy one.
- **`image_to_video` animates from frame 1** stage the first frame with `image_gen`/`image_edit` before animating.
- **Aspect ratio:** set it on the source image (`image_gen` `aspect_ratio`); don't re-crop an existing video.
- **Duration:** 6s or 10s only (prefer 6s); round to the nearest.
- **Real people:** reference-first drive the video from a verified reference image; never animate a named person without one.
- Don't loop the same clip unless asked.";
pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal";
pub const GOAL_COMMAND_NAME: &str = "goal";
/// Bare subcommand tokens reserved for goal lifecycle control rather than
/// being treated as an objective, matching the shell's /goal grammar.
pub const GOAL_RESERVED_SUBCOMMANDS: &[&str] = &["status", "pause", "resume", "clear", "edit"];
pub fn goal_usage_message() -> &'static str {
"Usage: /goal <objective>\n\
Set an objective to work toward until it is complete."
}
pub fn goal_instruction(objective: &str) -> String {
format!(
"# /goal -- pursue an objective\n\n\
A goal has been set: {objective}\n\n\
Work directly on this goal and carry it as far as you can. Deliver \
everything the user asked for yourself: no follow-up questions, no \
manual steps left for the user. If the conversation continues, keep \
pursuing the goal until it is complete.\n\n\
TRACKING: break the objective into concrete steps and track them \
(use your todo tool if one is available), marking each done as you \
finish it.\n\n\
VERIFY AS YOU GO: test each change on the real path before moving on. \
A completion claim must be backed by evidence produced in this \
session, not assumptions.\n\n\
Call update_goal(completed: true, message: \"summary\") ONLY when the \
goal is fully achieved. Call update_goal(blocked_reason: \"reason\") \
only when truly stuck after 3+ consecutive failed attempts at the \
same problem. Call update_goal(message: \"status note\") to log \
progress along the way. If update_goal returns an error, continue \
working the goal and report status in your reply instead.\n\n\
Start now."
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imagine_instruction_carries_prompt_verbatim() {
let text = imagine_instruction("a golden sunset");
assert!(text.contains("a golden sunset"));
assert!(text.contains("image_gen"));
assert!(text.contains("verbatim"));
}
#[test]
fn imagine_video_instruction_carries_prompt_and_workflow() {
let text = imagine_video_instruction("a cat playing piano");
assert!(text.contains("a cat playing piano"));
assert!(text.contains("image_to_video"));
assert!(text.contains("FFmpeg"));
}
#[test]
fn instruction_carries_args_and_contract_tokens() {
let text = loop_schedule_instruction("every 30 minutes do x");
assert!(text.contains("every 30 minutes do x"));
assert!(text.contains("<number><unit>"));
assert!(text.contains("ask the user how often"));
assert!(!text.contains("10m"), "no host-side default interval");
}
#[test]
fn goal_instruction_carries_objective_and_contract_tokens() {
let text = goal_instruction("ship the widget");
assert!(text.contains("ship the widget"));
assert!(text.contains("update_goal(completed: true"));
assert!(text.contains("blocked_reason"));
assert!(text.contains("If update_goal returns an error"));
assert!(
!text.contains("system-reminder"),
"expansions ride as user messages and must not claim reminder authority"
);
assert!(goal_usage_message().contains("Usage: /goal"));
}
#[test]
fn usage_message_has_no_default_claim() {
assert!(loop_usage_message().contains("Usage: /loop"));
assert!(!loop_usage_message().contains("10m"));
}
}