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:
@@ -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
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user