M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
license = "Apache-2.0"
name = "kigi-tool-types"
version.workspace = true
edition.workspace = true
description = "Canonical tool-description types for the xAI platform"
[features]
# Enables `BuiltinSubagent::render_prompt` (MiniJinja rendering of the
# built-in subagent prompt bodies).
prompt-render = ["dep:minijinja"]
[dependencies]
minijinja = { workspace = true, features = ["custom_syntax"], optional = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[lints]
workspace = true
+172
View File
@@ -0,0 +1,172 @@
//! In-memory, type-keyed extension storage.
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::fmt;
// Type-erased, clone-able value stored in the Extensions map.
struct Entry {
data: Box<dyn Any + Send + Sync>,
clone_fn: fn(&(dyn Any + Send + Sync)) -> Box<dyn Any + Send + Sync>,
}
impl Clone for Entry {
fn clone(&self) -> Self {
Self {
data: (self.clone_fn)(self.data.as_ref()),
clone_fn: self.clone_fn,
}
}
}
fn clone_any<T: Clone + Send + Sync + 'static>(
any: &(dyn Any + Send + Sync),
) -> Box<dyn Any + Send + Sync> {
Box::new(
any.downcast_ref::<T>()
.expect("type mismatch in Extensions clone")
.clone(),
)
}
/// Clone-able, type-keyed storage for metadata.
#[derive(Default, Clone)]
pub struct Extensions {
map: HashMap<TypeId, Entry>,
}
impl Extensions {
pub fn new() -> Self {
Self::default()
}
/// Retrieve a reference to a stored value by type.
pub fn get<T: Any + Send + Sync + 'static>(&self) -> Option<&T> {
self.map
.get(&TypeId::of::<T>())
.and_then(|e| e.data.downcast_ref())
}
/// Retrieve a mutable reference to a stored value by type.
pub fn get_mut<T: Any + Send + Sync + 'static>(&mut self) -> Option<&mut T> {
self.map
.get_mut(&TypeId::of::<T>())
.and_then(|e| e.data.downcast_mut())
}
/// Insert a value, replacing any previous value of the same type.
pub fn set<T: Clone + Send + Sync + 'static>(&mut self, val: T) {
self.map.insert(
TypeId::of::<T>(),
Entry {
data: Box::new(val),
clone_fn: clone_any::<T>,
},
);
}
/// Remove and return a value by type.
pub fn remove<T: Any + Send + Sync + 'static>(&mut self) -> Option<T> {
self.map
.remove(&TypeId::of::<T>())
.and_then(|e| e.data.downcast().ok())
.map(|b| *b)
}
/// Check if a value of the given type is stored.
pub fn contains<T: Any + Send + Sync + 'static>(&self) -> bool {
self.map.contains_key(&TypeId::of::<T>())
}
/// Number of stored entries.
pub fn len(&self) -> usize {
self.map.len()
}
/// Returns true if no entries are stored.
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
// Extensions are runtime-only metadata, not part of serialized identity.
impl PartialEq for Extensions {
fn eq(&self, _other: &Self) -> bool {
true
}
}
impl Eq for Extensions {}
impl fmt::Debug for Extensions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Extensions")
.field("len", &self.map.len())
.finish()
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq)]
struct LocalToolContext {
user_id: String,
conversation_id: String,
}
#[test]
fn set_and_get() {
let mut ext = Extensions::new();
ext.set(LocalToolContext {
user_id: "123".into(),
conversation_id: "456".into(),
});
let hints = ext.get::<LocalToolContext>().unwrap();
assert_eq!(hints.user_id, "123");
assert_eq!(hints.conversation_id, "456");
}
#[test]
fn get_mut() {
let mut ext = Extensions::new();
ext.set(LocalToolContext {
user_id: "123".into(),
conversation_id: "456".into(),
});
ext.get_mut::<LocalToolContext>().unwrap().conversation_id = "789".into();
assert_eq!(
ext.get::<LocalToolContext>().unwrap().conversation_id,
"789"
);
}
#[test]
fn missing_returns_none() {
let ext = Extensions::new();
assert!(ext.get::<LocalToolContext>().is_none());
assert!(!ext.contains::<LocalToolContext>());
}
#[test]
fn clone_preserves_values() {
let mut ext = Extensions::new();
ext.set(LocalToolContext {
user_id: "123".into(),
conversation_id: "456".into(),
});
let cloned = ext.clone();
assert_eq!(cloned.get::<LocalToolContext>().unwrap().user_id, "123");
assert_eq!(
cloned.get::<LocalToolContext>().unwrap().conversation_id,
"456"
);
}
}
+28
View File
@@ -0,0 +1,28 @@
//! Canonical, extensible tool types.
mod ext;
mod schema_utils;
pub mod serde_lenient;
mod task;
mod types;
pub use ext::Extensions;
pub use schema_utils::parse_arguments_from_schema_lossy;
pub use serde_lenient::{
deserialize_lenient_bool, deserialize_lenient_option_bool, lenient_bool_from_json,
};
pub use task::{
BUILTIN_SUBAGENTS, BuiltinSubagent, EXPLORE_PROMPT, EXPLORE_SUBAGENT, GENERAL_PURPOSE_PROMPT,
GENERAL_PURPOSE_SUBAGENT, KillTaskOutput, KillTaskResult, KillTaskToolInput,
KillTaskToolNaming, MAX_MULTI_WAIT_IDS, MultiTaskOutputResult, PLAN_PROMPT, PLAN_SUBAGENT,
SubagentCapabilityMode, SubagentCompletedOutput, SubagentDescriptor, SubagentIsolationMode,
SubagentToolNaming, TaskOutputOutput, TaskOutputResult, TaskOutputToolInput,
TaskOutputToolNaming, TaskToolInput, TaskToolNaming, WaitMode, WaitTasksToolInput,
WaitTasksToolNaming, build_kill_task_description, build_task_description,
build_task_output_description, build_wait_tasks_description, builtin_subagent_by_name,
default_subagent_type, format_resume_footer, format_subagent_completed,
format_subagent_started_background, is_not_sentinel, resolve_task_ids, sanitize_optional_arg,
task_output_waits, task_output_waits_from_json,
};
pub use types::{
ArgumentType, SchemaType, ToolArgument, ToolDescription, ValidationError, ValidationErrors,
};
@@ -0,0 +1,659 @@
use crate::types::{ArgumentType, SchemaType, ToolArgument};
use serde_json::Value;
use std::collections::HashSet;
/// Parse a JSON Schema "parameters" object into a list of [`ToolArgument`]s.
///
/// This function extracts flat, top-level properties from an
/// "object"-typed schema. It is intentionally a minimal subset of
/// JSON Schema — just enough for render tools to work as they don't speak JSON schema.
///
/// # Supported keywords
///
/// | Keyword | Scope | Notes |
/// |---|---|---|
/// | `properties` | top-level | Each key becomes a [`ToolArgument`] |
/// | `required` | top-level | Marks arguments as required |
/// | `$defs` | top-level | Resolved when referenced by `$ref` |
/// | `type` | per-property | String (`"string"`) or array (`["string", "null"]`) via [`SchemaType::from_value`] |
/// | `description` | per-property | Mapped to [`ToolArgument::description`] |
/// | `default` | per-property | Mapped to [`ToolArgument::default`] |
/// | `enum` | per-property | Mapped to [`ToolArgument::allowed_values`] |
/// | `minimum` / `maximum` | per-property | Inclusive numeric bounds |
/// | `exclusiveMinimum` / `exclusiveMaximum` | per-property | Exclusive numeric bounds |
/// | `$ref` | per-property | Resolved against `$defs` for enum types |
/// | `anyOf` | per-property | Resolved: `$ref` branches follow `$defs`, type-only branches infer [`SchemaType`] |
/// | `oneOf` | in `$defs` | `const` values extracted as enum variants |
///
/// For composite types (`array`, `object`), the entire property schema
/// is stored in [`ToolArgument::schema`] so downstream consumers can
/// inspect nested structure.
///
/// # Not supported (ignored)
///
/// The following JSON Schema features are **not** extracted and will be
/// silently dropped. Callers that need them should either pre-process
/// the schema or use the raw `schema` field on composite arguments.
///
/// - Composition: `allOf`
/// - Conditionals: `if` / `then` / `else`, `dependentRequired`,
/// `dependentSchemas`
/// - Object keywords: `patternProperties`, `additionalProperties`,
/// `propertyNames`, `minProperties`, `maxProperties`
/// - String keywords: `pattern`, `minLength`, `maxLength`, `format`
/// - Array keywords: `items`, `prefixItems`, `minItems`, `maxItems`,
/// `uniqueItems`
///
/// # Returns
///
/// An empty `Vec` when `schema` has no `"properties"` key (or it is
/// not an object).
///
/// JSON Schema spec: <https://json-schema.org/understanding-json-schema>
pub fn parse_arguments_from_schema_lossy(schema: &serde_json::Value) -> Vec<ToolArgument> {
let properties = match schema.get("properties").and_then(|p| p.as_object()) {
Some(p) => p,
None => return Vec::new(),
};
let required: HashSet<&str> = schema
.get("required")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let defs = schema.get("$defs").and_then(|v| v.as_object());
properties
.iter()
.map(|(name, prop)| {
let (resolved_type, resolved_values, resolved_default, resolved_schema) =
resolve_ref_type(prop.as_object(), defs);
let arg_type = resolved_type.unwrap_or_else(|| {
prop.get("type")
.map(SchemaType::from_value)
.unwrap_or_default()
});
let description = prop
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
let default = resolved_default.or_else(|| prop.get("default").cloned());
let is_required = required.contains(name.as_str());
let allowed_values = if let Some(vals) = resolved_values {
vals
} else {
prop.get("allowed_values")
.or_else(|| prop.get("enum"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
};
let schema = if let Some(raw) = resolved_schema {
Some(raw)
} else if arg_type.is_composite() {
Some(prop.clone())
} else {
None
};
let minimum = prop.get("minimum").and_then(|v| v.as_number()).cloned();
let maximum = prop.get("maximum").and_then(|v| v.as_number()).cloned();
let exclusive_minimum = prop
.get("exclusiveMinimum")
.and_then(|v| v.as_number())
.cloned();
let exclusive_maximum = prop
.get("exclusiveMaximum")
.and_then(|v| v.as_number())
.cloned();
let mut arg = ToolArgument::new(name.clone(), description)
.with_type(arg_type)
.with_allowed_values(allowed_values);
if !is_required {
arg = arg.set_optional();
}
if let Some(d) = default {
arg = arg.with_default(d);
}
if let Some(s) = schema {
arg = arg.with_schema(s);
}
if let Some(min) = minimum {
arg = arg.with_minimum(min);
}
if let Some(max) = maximum {
arg = arg.with_maximum(max);
}
if let Some(min) = exclusive_minimum {
arg = arg.with_exclusive_minimum(min);
}
if let Some(max) = exclusive_maximum {
arg = arg.with_exclusive_maximum(max);
}
arg
})
.collect()
}
// ---------------------------------------------------------------------------
// $ref / $defs / anyOf / oneOf resolution
// ---------------------------------------------------------------------------
/// Resolve type info from a property, following `$ref` → `$defs` and
/// `anyOf` patterns that schemars generates for Rust enums and
/// `Option<Enum>` types.
///
/// Returns `(type, allowed_values, default, raw_schema)`.
fn resolve_ref_type(
prop: Option<&serde_json::Map<String, Value>>,
defs: Option<&serde_json::Map<String, Value>>,
) -> (
Option<SchemaType>,
Option<Vec<Value>>,
Option<Value>,
Option<Value>,
) {
let prop = match prop {
Some(p) => p,
None => return (None, None, None, None),
};
// Pattern 1: `anyOf` — schemars uses this for `Option<Enum>` and
// union types.
if let Some(any_of) = prop.get("anyOf").and_then(|v| v.as_array()) {
// Check if any branch is a `$ref` to a `$defs` enum.
for item in any_of {
if let Some(ref_path) = item.get("$ref").and_then(|v| v.as_str())
&& let Some(enum_name) = ref_path.strip_prefix("#/$defs/")
&& let Some(enum_def) = defs.and_then(|d| d.get(enum_name))
{
let (ty, vals, def) = extract_enum_from_def(enum_def);
return (ty.map(SchemaType::Single), vals, def, None);
}
}
// No $ref found — infer a union type from the branches' `type` fields.
if prop.get("type").is_none() {
let types: Vec<ArgumentType> = any_of
.iter()
.filter_map(|branch| {
branch
.get("type")
.and_then(|t| t.as_str())
.and_then(ArgumentType::from_schema_type)
})
.collect();
let schema_type = match types.len() {
0 => None,
1 => Some(SchemaType::Single(types[0])),
_ => Some(SchemaType::Multiple(types)),
};
if schema_type.is_some() {
return (schema_type, None, None, Some(Value::Object(prop.clone())));
}
}
}
// Pattern 2: direct `$ref` (no `anyOf` wrapper) — schemars uses
// this for non-optional enum fields.
if let Some(ref_path) = prop.get("$ref").and_then(|v| v.as_str())
&& let Some(enum_name) = ref_path.strip_prefix("#/$defs/")
&& let Some(enum_def) = defs.and_then(|d| d.get(enum_name))
{
let (ty, vals, def) = extract_enum_from_def(enum_def);
return (ty.map(SchemaType::Single), vals, def, None);
}
(None, None, None, None)
}
/// Extract enum info from a `$defs` entry.
///
/// Handles two schemars patterns:
/// - Compact: `{ "type": "string", "enum": ["a", "b"] }`
/// - oneOf: `{ "oneOf": [{ "const": "a" }, { "const": "b" }] }`
fn extract_enum_from_def(
enum_def: &Value,
) -> (Option<ArgumentType>, Option<Vec<Value>>, Option<Value>) {
let Some(obj) = enum_def.as_object() else {
return (None, None, None);
};
// Compact form: `"enum": [...]`
if let Some(values) = obj.get("enum").and_then(|v| v.as_array())
&& !values.is_empty()
{
let first_value = values.first().cloned();
let arg_type = infer_arg_type(&first_value);
return (Some(arg_type), Some(values.clone()), first_value);
}
// oneOf form: `"oneOf": [{"const": ...}, ...]`
let Some(one_of) = obj.get("oneOf").and_then(|v| v.as_array()) else {
return (None, None, None);
};
let mut values = Vec::new();
let mut first_value = None;
for variant in one_of {
if let Some(const_val) = variant.get("const") {
if first_value.is_none() {
first_value = Some(const_val.clone());
}
values.push(const_val.clone());
}
}
if values.is_empty() {
return (None, None, None);
}
let arg_type = infer_arg_type(&first_value);
(Some(arg_type), Some(values), first_value)
}
/// Infer the [`ArgumentType`] from a sample enum value.
fn infer_arg_type(sample: &Option<Value>) -> ArgumentType {
match sample {
Some(Value::String(_)) => ArgumentType::String,
Some(Value::Number(n)) if n.is_i64() || n.is_u64() => ArgumentType::Integer,
Some(Value::Number(_)) => ArgumentType::Number,
Some(Value::Bool(_)) => ArgumentType::Boolean,
_ => ArgumentType::String,
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ArgumentType;
#[test]
fn parse_schema_basic() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"limit": {
"type": "integer",
"description": "Max results",
"default": 10
}
},
"required": ["query"]
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args.len(), 2);
let query = args.iter().find(|a| a.name == "query").unwrap();
assert_eq!(query.arg_type, ArgumentType::String);
assert!(query.required);
assert!(query.default.is_none());
let limit = args.iter().find(|a| a.name == "limit").unwrap();
assert_eq!(limit.arg_type, ArgumentType::Integer);
assert!(!limit.required);
assert_eq!(limit.default, Some(serde_json::json!(10)));
}
#[test]
fn parse_schema_enum_values() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"mode": {
"type": "string",
"description": "Processing mode",
"enum": ["fast", "slow", "auto"],
"default": "auto"
}
}
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args.len(), 1);
assert_eq!(args[0].allowed_values.len(), 3);
assert!(!args[0].required);
}
#[test]
fn parse_schema_composite_stores_schema() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"filters": {
"type": "object",
"description": "Filter object",
"properties": {
"key": { "type": "string" }
}
},
"tags": {
"type": "array",
"description": "Tag list",
"items": { "type": "string" }
}
},
"required": ["filters"]
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args.len(), 2);
let filters = args.iter().find(|a| a.name == "filters").unwrap();
assert_eq!(filters.arg_type, ArgumentType::Object);
assert!(filters.schema.is_some());
assert!(filters.required);
let tags = args.iter().find(|a| a.name == "tags").unwrap();
assert_eq!(tags.arg_type, ArgumentType::Array);
assert!(tags.schema.is_some());
assert!(!tags.required);
}
#[test]
fn parse_schema_primitives_no_schema() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"flag": { "type": "boolean", "description": "A flag" }
}
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args[0].arg_type, ArgumentType::Boolean);
assert!(args[0].schema.is_none());
}
#[test]
fn parse_schema_empty_or_missing() {
assert!(parse_arguments_from_schema_lossy(&serde_json::json!({})).is_empty());
assert!(parse_arguments_from_schema_lossy(&serde_json::json!(null)).is_empty());
assert!(
parse_arguments_from_schema_lossy(&serde_json::json!({"properties": null})).is_empty()
);
}
#[test]
fn parse_schema_required_and_default_orthogonal() {
// Per JSON Schema, required and default are independent.
let schema = serde_json::json!({
"type": "object",
"properties": {
"x": { "type": "integer", "description": "test", "default": 1 }
},
"required": ["x"]
});
let args = parse_arguments_from_schema_lossy(&schema);
assert!(args[0].required, "required should be preserved");
assert_eq!(args[0].default, Some(serde_json::json!(1)));
}
#[test]
fn parse_schema_null_type() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"placeholder": {
"type": "null",
"description": "Always null"
}
}
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args.len(), 1);
assert_eq!(args[0].arg_type, ArgumentType::Null);
}
#[test]
fn parse_schema_nullable_string() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": ["string", "null"],
"description": "Optional name"
}
},
"required": ["name"]
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args.len(), 1);
assert!(args[0].arg_type.is_nullable());
assert_eq!(args[0].arg_type.primary_type(), ArgumentType::String);
}
#[test]
fn parse_schema_multi_type_no_null() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"value": {
"type": ["string", "integer"],
"description": "String or integer"
}
}
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args.len(), 1);
assert!(!args[0].arg_type.is_nullable());
assert!(args[0].arg_type.contains(ArgumentType::String));
assert!(args[0].arg_type.contains(ArgumentType::Integer));
}
// -- $ref / $defs / anyOf resolution ----------------------------------------
#[test]
fn parse_schema_any_of_ref_resolves_enum() {
// schemars pattern for `Option<MyEnum>` with oneOf-style defs.
let schema = serde_json::json!({
"type": "object",
"$defs": {
"MyEnum": {
"oneOf": [
{"const": "a"},
{"const": "b"}
]
}
},
"properties": {
"mode": {
"anyOf": [
{"$ref": "#/$defs/MyEnum"},
{"type": "null"}
],
"default": null,
"description": "The mode"
}
},
"required": ["mode"]
});
let args = parse_arguments_from_schema_lossy(&schema);
let mode = &args[0];
assert_eq!(mode.arg_type, ArgumentType::String);
assert_eq!(mode.allowed_values, vec!["a", "b"]);
// The resolved default is "a" (first enum variant), not null.
assert_eq!(mode.default, Some(serde_json::json!("a")));
assert!(mode.schema.is_none());
}
#[test]
fn parse_schema_direct_ref_resolves_enum() {
// schemars pattern for a required (non-Option) enum field.
let schema = serde_json::json!({
"type": "object",
"$defs": {
"Color": {
"oneOf": [
{"const": "red"},
{"const": "blue"}
]
}
},
"properties": {
"color": {
"$ref": "#/$defs/Color",
"description": "Pick a color"
}
},
"required": ["color"]
});
let args = parse_arguments_from_schema_lossy(&schema);
let color = &args[0];
assert_eq!(color.arg_type, ArgumentType::String);
assert_eq!(color.allowed_values, vec!["red", "blue"]);
}
#[test]
fn parse_schema_compact_enum_in_defs() {
// schemars pattern with compact `"enum": [...]` in $defs.
let schema = serde_json::json!({
"type": "object",
"$defs": {
"Duration": {
"type": "string",
"enum": ["6", "10"]
}
},
"properties": {
"dur_optional": {
"anyOf": [
{"$ref": "#/$defs/Duration"},
{"type": "null"}
],
"default": null,
"description": "Duration (optional)"
},
"dur_required": {
"$ref": "#/$defs/Duration",
"description": "Duration (required)"
}
},
"required": ["dur_optional", "dur_required"]
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args.len(), 2);
let opt = args.iter().find(|a| a.name == "dur_optional").unwrap();
assert_eq!(opt.allowed_values, vec!["6", "10"]);
let req = args.iter().find(|a| a.name == "dur_required").unwrap();
assert_eq!(req.allowed_values, vec!["6", "10"]);
}
#[test]
fn parse_schema_any_of_union_type_without_ref() {
// anyOf without $ref — infer union type from branches.
let schema = serde_json::json!({
"type": "object",
"properties": {
"to": {
"anyOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
],
"description": "Recipients"
}
},
"required": ["to"]
});
let args = parse_arguments_from_schema_lossy(&schema);
let to = &args[0];
assert_eq!(to.arg_type.primary_type(), ArgumentType::String);
assert!(to.required);
assert!(to.schema.is_some(), "anyOf schema should be stored");
}
#[test]
fn parse_schema_no_defs_still_works() {
// Properties with no $ref/$defs should work exactly as before.
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "A name",
"enum": ["x", "y"],
"default": "x"
}
}
});
let args = parse_arguments_from_schema_lossy(&schema);
assert_eq!(args[0].allowed_values, vec!["x", "y"]);
assert_eq!(args[0].default, Some(serde_json::json!("x")));
}
// -- numeric constraints --------------------------------------------------
#[test]
fn parse_schema_numeric_constraints() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"offset": {
"type": "integer",
"description": "Start line",
"minimum": 0,
"default": 1
},
"limit": {
"type": "integer",
"description": "Line count",
"exclusiveMinimum": 0,
"default": 2000
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds",
"minimum": 0,
"maximum": 600
}
},
"required": ["timeout"]
});
let args = parse_arguments_from_schema_lossy(&schema);
let offset = args.iter().find(|a| a.name == "offset").unwrap();
assert_eq!(offset.minimum, Some(serde_json::Number::from(0)));
assert!(offset.maximum.is_none());
assert!(offset.exclusive_minimum.is_none());
let limit = args.iter().find(|a| a.name == "limit").unwrap();
assert_eq!(limit.exclusive_minimum, Some(serde_json::Number::from(0)));
assert!(limit.minimum.is_none());
let timeout = args.iter().find(|a| a.name == "timeout").unwrap();
assert_eq!(timeout.minimum, Some(serde_json::Number::from(0)));
assert_eq!(timeout.maximum, Some(serde_json::Number::from(600)));
assert!(timeout.required);
}
}
@@ -0,0 +1,195 @@
//! Lenient deserializers for tool-argument booleans: a boolean may arrive as a
//! JSON string (`"true"`) or number (`1`) when a client doesn't coerce args
//! against the tool schema. Accepted forms (strings case-insensitive, trimmed;
//! `null` is `false`):
//!
//! | Truthy | Falsy |
//! |---------------------------------------|------------------------------------------------|
//! | `true`, `"true"`, `"yes"`, `"1"`, `1` | `false`, `"false"`, `"no"`, `"0"`, `0`, `null` |
use serde::Deserialize;
const TRUE_LITERALS: [&str; 3] = ["true", "yes", "1"];
const FALSE_LITERALS: [&str; 3] = ["false", "no", "0"];
/// Parse a JSON value into a `bool` per the accepted forms; `None` otherwise.
pub fn lenient_bool_from_json(value: &serde_json::Value) -> Option<bool> {
match value {
serde_json::Value::Bool(b) => Some(*b),
serde_json::Value::Null => Some(false),
serde_json::Value::String(s) => {
let trimmed = s.trim();
if TRUE_LITERALS
.iter()
.any(|lit| trimmed.eq_ignore_ascii_case(lit))
{
Some(true)
} else if FALSE_LITERALS
.iter()
.any(|lit| trimmed.eq_ignore_ascii_case(lit))
{
Some(false)
} else {
None
}
}
serde_json::Value::Number(n) => match n.as_i64() {
Some(1) => Some(true),
Some(0) => Some(false),
_ => None,
},
_ => None,
}
}
fn invalid_bool_message(value: &serde_json::Value) -> String {
format!(
"expected a boolean (true/false, \"true\"/\"false\", \"yes\"/\"no\", \"1\"/\"0\", 1/0), got {value}"
)
}
/// Deserialize a required `bool`; pair with `#[serde(default)]` so an absent key
/// uses the field default.
pub fn deserialize_lenient_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
lenient_bool_from_json(&value)
.ok_or_else(|| serde::de::Error::custom(invalid_bool_message(&value)))
}
/// Deserialize `Option<bool>`: absent key → `None` (via `#[serde(default)]`),
/// explicit `null` → `Some(false)`.
pub fn deserialize_lenient_option_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
lenient_bool_from_json(&value)
.map(Some)
.ok_or_else(|| serde::de::Error::custom(invalid_bool_message(&value)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_native_bools() {
assert_eq!(lenient_bool_from_json(&json!(true)), Some(true));
assert_eq!(lenient_bool_from_json(&json!(false)), Some(false));
}
#[test]
fn parses_string_true_false() {
assert_eq!(lenient_bool_from_json(&json!("true")), Some(true));
assert_eq!(lenient_bool_from_json(&json!("false")), Some(false));
}
#[test]
fn parses_yes_no() {
assert_eq!(lenient_bool_from_json(&json!("yes")), Some(true));
assert_eq!(lenient_bool_from_json(&json!("no")), Some(false));
}
#[test]
fn parses_string_one_zero() {
assert_eq!(lenient_bool_from_json(&json!("1")), Some(true));
assert_eq!(lenient_bool_from_json(&json!("0")), Some(false));
}
#[test]
fn parses_numeric_one_zero() {
assert_eq!(lenient_bool_from_json(&json!(1)), Some(true));
assert_eq!(lenient_bool_from_json(&json!(0)), Some(false));
}
#[test]
fn is_case_insensitive_and_trims() {
assert_eq!(lenient_bool_from_json(&json!("TRUE")), Some(true));
assert_eq!(lenient_bool_from_json(&json!("False")), Some(false));
assert_eq!(lenient_bool_from_json(&json!(" yes ")), Some(true));
assert_eq!(lenient_bool_from_json(&json!("No")), Some(false));
}
#[test]
fn parses_null_as_false() {
assert_eq!(lenient_bool_from_json(&json!(null)), Some(false));
}
#[test]
fn rejects_unknown_forms() {
for v in [
json!("maybe"),
json!(""),
json!(2),
json!(-1),
json!(1.5),
json!(1.0),
json!([]),
json!({}),
] {
assert_eq!(lenient_bool_from_json(&v), None, "should reject {v}");
}
}
fn deser_bool(json_str: &str) -> Result<bool, serde_json::Error> {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default, deserialize_with = "deserialize_lenient_bool")]
value: bool,
}
Ok(serde_json::from_str::<Wrapper>(json_str)?.value)
}
fn deser_opt_bool(json_str: &str) -> Result<Option<bool>, serde_json::Error> {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default, deserialize_with = "deserialize_lenient_option_bool")]
value: Option<bool>,
}
Ok(serde_json::from_str::<Wrapper>(json_str)?.value)
}
#[test]
fn required_accepts_all_forms() {
assert!(deser_bool(r#"{"value":true}"#).unwrap());
assert!(deser_bool(r#"{"value":"true"}"#).unwrap());
assert!(deser_bool(r#"{"value":"yes"}"#).unwrap());
assert!(deser_bool(r#"{"value":"1"}"#).unwrap());
assert!(deser_bool(r#"{"value":1}"#).unwrap());
assert!(!deser_bool(r#"{"value":"no"}"#).unwrap());
assert!(!deser_bool(r#"{"value":0}"#).unwrap());
}
#[test]
fn required_missing_uses_default() {
assert!(!deser_bool(r#"{}"#).unwrap());
}
#[test]
fn required_null_is_false() {
assert!(!deser_bool(r#"{"value":null}"#).unwrap());
}
#[test]
fn required_rejects_unknown() {
let err = deser_bool(r#"{"value":"maybe"}"#).unwrap_err();
assert!(err.to_string().contains("expected a boolean"));
}
#[test]
fn optional_missing_is_none_but_null_is_false() {
assert_eq!(deser_opt_bool(r#"{}"#).unwrap(), None);
assert_eq!(deser_opt_bool(r#"{"value":null}"#).unwrap(), Some(false));
}
#[test]
fn optional_parses_and_rejects() {
assert_eq!(deser_opt_bool(r#"{"value":"yes"}"#).unwrap(), Some(true));
assert_eq!(deser_opt_bool(r#"{"value":0}"#).unwrap(), Some(false));
assert!(deser_opt_bool(r#"{"value":"nope"}"#).is_err());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff