#![forbid(unsafe_code)]
use std::collections::HashSet;
use anyhow::Context as _;
use kcode_kennedy_session_kweb_contracts::{
DecodedKwebTool, canonical_node_ids, decode as decode_kweb,
};
use kcode_session_history::chatend::BoxId;
use serde_json::Value;
#[derive(Clone, Debug, PartialEq)]
pub enum DecodedTool {
RunSubagent {
model: String,
reasoning_effort: Option<String>,
context_node_ids: Vec<String>,
task: String,
},
EndSession {
message: Option<String>,
},
BoxIds(Vec<BoxId>),
SummarizeBox {
box_id: BoxId,
summary: String,
},
BoxId(BoxId),
LoadNodes(Vec<String>),
EmitObject {
object_id: String,
file_name: Option<String>,
},
WebSearch {
question: String,
model: String,
},
WebFetch(String),
StageTelegramGroupMedia(i64),
MediaEnrichment {
object_id: String,
model: String,
prompt: String,
},
GenerateImage {
model: String,
prompt: String,
reference_object_ids: Vec<String>,
},
ObjectId(String),
ConnectNodes(Vec<String>),
ConsolidateFanout {
parent: String,
fanout: Vec<String>,
aggregator: String,
},
SetFixedConnection {
parent: String,
child: Option<String>,
slot: usize,
},
CreateNode {
parents: Vec<String>,
owner: String,
short_name: String,
short_description: String,
long_description: String,
},
UpdateNode {
id: String,
owner: String,
short_name: String,
short_description: String,
long_description: String,
},
}
impl From<DecodedKwebTool> for DecodedTool {
fn from(value: DecodedKwebTool) -> Self {
match value {
DecodedKwebTool::ConnectNodes(identifiers) => Self::ConnectNodes(identifiers),
DecodedKwebTool::ConsolidateFanout {
parent,
fanout,
aggregator,
} => Self::ConsolidateFanout {
parent,
fanout,
aggregator,
},
DecodedKwebTool::SetFixedConnection {
parent,
child,
slot,
} => Self::SetFixedConnection {
parent,
child,
slot,
},
DecodedKwebTool::CreateNode {
parents,
owner,
short_name,
short_description,
long_description,
} => Self::CreateNode {
parents,
owner,
short_name,
short_description,
long_description,
},
DecodedKwebTool::UpdateNode {
id,
owner,
short_name,
short_description,
long_description,
} => Self::UpdateNode {
id,
owner,
short_name,
short_description,
long_description,
},
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum ValidationRequest<'a> {
Annotation {
model: &'a str,
media_type: &'a str,
},
ImageModel(&'a str),
TranscriptionModel(&'a str),
TranscribableAudio(&'a str),
ExtractableDocument {
media_type: &'a str,
file_name: &'a str,
},
}
#[derive(Clone, Copy, Debug)]
pub enum ManagedObjectArguments<'a> {
RustBinary(&'a Value),
WebLibraryAttachment(&'a Value),
}
pub fn decode(tool: &str, value: &Value) -> anyhow::Result<Option<DecodedTool>> {
if let Some(decoded) = decode_kweb(tool, value)? {
return Ok(Some(decoded.into()));
}
let decoded = match tool {
"RunSubagent" => {
exact(
value,
&["model", "contextNodeIds", "task"],
&["reasoningEffort"],
)?;
DecodedTool::RunSubagent {
model: nonempty(value, "model", 128)?,
reasoning_effort: value
.get("reasoningEffort")
.map(|_| nonempty(value, "reasoningEffort", 32))
.transpose()?,
task: bounded_nonempty(value, "task", 100_000)?,
context_node_ids: canonical_node_ids(value, "contextNodeIds", Some(64), false)?,
}
}
"EndSession" => {
exact(value, &[], &["message"])?;
DecodedTool::EndSession {
message: value
.get("message")
.and_then(Value::as_str)
.map(str::to_owned),
}
}
"DehydrateBoxes" | "BoxesIntoObjects" => {
exact(value, &["boxIds"], &[])?;
DecodedTool::BoxIds(box_ids(value, "boxIds")?)
}
"SummarizeBox" => {
exact(value, &["boxId", "summary"], &[])?;
DecodedTool::SummarizeBox {
box_id: BoxId(positive_integer(value, "boxId")?),
summary: nonempty(value, "summary", 1_000_000)?,
}
}
"HydrateBox" => {
exact(value, &["boxId"], &[])?;
DecodedTool::BoxId(BoxId(positive_integer(value, "boxId")?))
}
"LoadNodes" => {
exact(value, &["identifiers"], &[])?;
DecodedTool::LoadNodes(canonical_node_ids(value, "identifiers", None, true)?)
}
"EmitObject" => {
exact(value, &["objectId"], &["fileName"])?;
DecodedTool::EmitObject {
object_id: nonempty(value, "objectId", 64)?,
file_name: delivery_file_name(value, "fileName")?,
}
}
"WebSearch" => {
exact(value, &["question", "model"], &[])?;
let model = nonempty(value, "model", 128)?;
let question = nonempty(value, "question", 4_000)?;
DecodedTool::WebSearch { question, model }
}
"WebFetch" => {
exact(value, &["url"], &[])?;
DecodedTool::WebFetch(nonempty(value, "url", 4_096)?)
}
"StageTelegramGroupMedia" => {
exact(value, &["messageId"], &[])?;
DecodedTool::StageTelegramGroupMedia(
i64::try_from(positive_integer(value, "messageId")?)
.context("messageId exceeds Telegram's supported integer range")?,
)
}
"TranscribeAudio" | "AnnotateMedia" => {
exact(value, &["objectId", "model", "prompt"], &[])?;
let model = nonempty(value, "model", 128)?;
let prompt = nonblank(value, "prompt")?;
let object_id = nonempty(value, "objectId", 64)?;
DecodedTool::MediaEnrichment {
object_id,
model,
prompt,
}
}
"GenerateImage" => {
exact(value, &["model", "prompt"], &["referenceObjectIds"])?;
let model = nonempty(value, "model", 128)?;
validate(ValidationRequest::ImageModel(&model))?;
DecodedTool::GenerateImage {
model,
prompt: bounded_nonempty(value, "prompt", 100_000)?,
reference_object_ids: optional_object_ids(value, "referenceObjectIds", 14)?,
}
}
"ExtractDocumentText" => {
exact(value, &["objectId"], &[])?;
DecodedTool::ObjectId(nonempty(value, "objectId", 64)?)
}
_ => return Ok(None),
};
Ok(Some(decoded))
}
pub fn validate(request: ValidationRequest<'_>) -> anyhow::Result<()> {
match request {
ValidationRequest::Annotation { model, media_type } => match model {
"gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-5.6-luna" => anyhow::ensure!(
media_type.starts_with("image/"),
"{model} annotations accept images only"
),
"gemini-2.5-flash" | "gemini-3.1-flash-lite" | "gemini-3.1-pro-preview" => {
anyhow::ensure!(
media_type.starts_with("image/")
|| media_type.starts_with("audio/")
|| media_type.starts_with("video/")
|| media_type == "application/ogg",
"{model} annotations accept images, audio, or video only"
)
}
_ => anyhow::bail!("unsupported exact annotation model {model}"),
},
ValidationRequest::ImageModel(model) => anyhow::ensure!(
matches!(model, "gpt-image-2" | "gemini-3-pro-image"),
"unsupported exact image model {model}; use gpt-image-2 or gemini-3-pro-image"
),
ValidationRequest::TranscriptionModel(model) => anyhow::ensure!(
matches!(
model,
"gpt-4o-transcribe"
| "gemini-2.5-flash"
| "gemini-3.1-flash-lite"
| "gemini-3.1-pro-preview"
),
"unsupported exact transcription model {model}"
),
ValidationRequest::TranscribableAudio(media_type) => anyhow::ensure!(
matches!(
media_type,
"audio/flac"
| "audio/x-flac"
| "audio/m4a"
| "audio/mp3"
| "audio/mp4"
| "audio/mpeg"
| "audio/mpga"
| "audio/ogg"
| "audio/opus"
| "audio/wav"
| "audio/x-wav"
| "audio/webm"
| "application/ogg"
),
"TranscribeAudio accepts a supported FLAC, MP3, MP4, M4A, OGG, WAV, or WebM audio object only"
),
ValidationRequest::ExtractableDocument {
media_type,
file_name,
} => {
let media_type = normalize_media_type(media_type);
let extension = file_name
.rsplit_once('.')
.map(|(_, extension)| extension.to_ascii_lowercase());
let supported_media_type = media_type.starts_with("text/")
|| matches!(
media_type.as_str(),
"application/pdf"
| "application/msword"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
| "application/vnd.ms-excel"
| "application/vnd.ms-excel.sheet.binary.macroenabled.12"
| "application/vnd.oasis.opendocument.spreadsheet"
| "application/json"
| "application/xml"
| "application/yaml"
| "application/x-yaml"
);
anyhow::ensure!(
supported_media_type
|| matches!(
extension.as_deref(),
Some(
"pdf"
| "doc"
| "docx"
| "xlsx"
| "xls"
| "xlsb"
| "ods"
| "csv"
| "tsv"
| "txt"
| "md"
| "json"
| "yaml"
| "yml"
| "xml"
)
),
"ExtractDocumentText accepts supported PDF, Word, spreadsheet, and text-family objects only"
);
}
}
Ok(())
}
pub fn decode_managed_objects(request: ManagedObjectArguments<'_>) -> anyhow::Result<Vec<String>> {
match request {
ManagedObjectArguments::RustBinary(arguments) => {
let Some(ids) = arguments.get("objectIds") else {
return Ok(Vec::new());
};
ids.as_array()
.context("Rust-binary objectIds must be an array")?
.iter()
.map(|id| {
id.as_str()
.map(str::to_owned)
.context("Rust-binary objectIds must contain only strings")
})
.collect()
}
ManagedObjectArguments::WebLibraryAttachment(arguments) => Ok(vec![
arguments
.get("objectId")
.and_then(Value::as_str)
.filter(|id| !id.trim().is_empty())
.map(str::to_owned)
.context("Web-library attachment objectId must be a nonempty string")?,
]),
}
}
fn exact(value: &Value, required: &[&str], optional: &[&str]) -> anyhow::Result<()> {
let map = value
.as_object()
.context("arguments must be a JSON object")?;
let allowed = required
.iter()
.chain(optional)
.copied()
.collect::<HashSet<_>>();
anyhow::ensure!(
required.iter().all(|key| map.contains_key(*key))
&& map.keys().all(|key| allowed.contains(key.as_str())),
"expected exactly: {}{}",
required.join(", "),
if optional.is_empty() {
String::new()
} else {
format!(" (optional: {})", optional.join(", "))
}
);
Ok(())
}
fn positive_integer(value: &Value, key: &str) -> anyhow::Result<u64> {
value
.get(key)
.and_then(Value::as_u64)
.filter(|value| *value > 0)
.with_context(|| format!("{key} must be a positive integer"))
}
fn box_ids(value: &Value, key: &str) -> anyhow::Result<Vec<BoxId>> {
let ids = value
.get(key)
.and_then(Value::as_array)
.with_context(|| format!("{key} must be an array"))?
.iter()
.map(|value| {
value
.as_u64()
.filter(|value| *value > 0)
.map(BoxId)
.with_context(|| format!("{key} must contain only positive integers"))
})
.collect::<anyhow::Result<Vec<_>>>()?;
anyhow::ensure!(!ids.is_empty(), "{key} must contain at least one box ID");
anyhow::ensure!(
ids.iter().copied().collect::<HashSet<_>>().len() == ids.len(),
"{key} must not contain duplicate box IDs"
);
Ok(ids)
}
fn string(value: &Value, key: &str) -> anyhow::Result<String> {
value
.get(key)
.and_then(Value::as_str)
.map(str::to_owned)
.with_context(|| format!("{key} must be a string"))
}
fn nonempty(value: &Value, key: &str, maximum: usize) -> anyhow::Result<String> {
let value = string(value, key)?;
let trimmed = value.trim();
anyhow::ensure!(
!trimmed.is_empty() && trimmed.chars().count() <= maximum,
"{key} must contain between 1 and {maximum} characters"
);
Ok(trimmed.into())
}
fn nonblank(value: &Value, key: &str) -> anyhow::Result<String> {
let value = string(value, key)?;
anyhow::ensure!(!value.trim().is_empty(), "{key} must not be blank");
Ok(value)
}
fn bounded_nonempty(value: &Value, key: &str, maximum: usize) -> anyhow::Result<String> {
let value = string(value, key)?;
anyhow::ensure!(
!value.trim().is_empty() && value.chars().count() <= maximum,
"{key} must contain between 1 and {maximum} characters"
);
Ok(value)
}
fn optional_object_ids(value: &Value, key: &str, maximum: usize) -> anyhow::Result<Vec<String>> {
let Some(values) = value.get(key) else {
return Ok(Vec::new());
};
let values = values
.as_array()
.with_context(|| format!("{key} must be an array"))?;
anyhow::ensure!(
values.len() <= maximum,
"{key} must contain at most {maximum} object IDs"
);
let ids = values
.iter()
.map(|value| {
let id = value
.as_str()
.with_context(|| format!("{key} entries must be strings"))?;
anyhow::ensure!(
!id.trim().is_empty() && id.chars().count() <= 64,
"{key} entries must contain between 1 and 64 characters"
);
Ok(id.to_owned())
})
.collect::<anyhow::Result<Vec<_>>>()?;
anyhow::ensure!(
ids.iter().collect::<HashSet<_>>().len() == ids.len(),
"{key} must not contain duplicate object IDs"
);
Ok(ids)
}
fn delivery_file_name(value: &Value, key: &str) -> anyhow::Result<Option<String>> {
let Some(file_name) = value.get(key) else {
return Ok(None);
};
let file_name = file_name
.as_str()
.with_context(|| format!("{key} must be a string"))?;
kcode_telegram_session_coordinator::validate_file_name(file_name)?;
Ok(Some(file_name.to_owned()))
}
fn normalize_media_type(value: &str) -> String {
value
.split(';')
.next()
.unwrap_or(value)
.trim()
.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn transcribe_preserves_long_nonblank_prompt_and_rejects_optional_fields() {
let prompt = "x".repeat(4 * 1024 * 1024 + 1);
let decoded = decode(
"TranscribeAudio",
&json!({
"objectId":"pending:1",
"model":"gpt-4o-transcribe",
"prompt":prompt
}),
)
.unwrap()
.unwrap();
let DecodedTool::MediaEnrichment { prompt: actual, .. } = decoded else {
panic!("wrong decoded variant");
};
assert_eq!(actual.len(), 4 * 1024 * 1024 + 1);
assert!(
decode(
"TranscribeAudio",
&json!({
"objectId":"pending:1",
"model":"gpt-4o-transcribe",
"prompt":"audio",
"temperature":0.0
}),
)
.is_err()
);
}
#[test]
fn image_validation_order_precedes_prompt_and_reference_validation() {
let error = decode(
"GenerateImage",
&json!({
"model":"unknown",
"prompt":"",
"referenceObjectIds":"not-an-array"
}),
)
.unwrap_err()
.to_string();
assert!(error.contains("unsupported exact image model"));
}
#[test]
fn kweb_decode_preserves_character_limits() {
let error = decode(
"CreateNode",
&json!({
"parentIdentifiers":["self"],
"ownerIdentifier":"self",
"shortName":"abc",
"shortDescription":"",
"longDescription":""
}),
)
.unwrap_err()
.to_string();
assert!(error.contains("received 3"));
}
}