use crate::{McpSourceSpec, PromptSource};
use llm::{ModelSettings, ProviderConnectionOverrides, ReasoningEffort};
use mcp_utils::client::ToolFilter;
#[doc = include_str!("docs/agent_config.md")]
#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[schemars(transform = require_agent_invocation_surface_schema)]
pub struct AgentConfig {
#[schemars(length(min = 1))]
pub name: String,
#[schemars(length(min = 1))]
pub description: String,
#[schemars(length(min = 1))]
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "ModelSettings::is_empty")]
pub model_settings: ModelSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub context_window: Option<u32>,
#[serde(default)]
pub user_invocable: bool,
#[serde(default)]
pub agent_invocable: bool,
#[serde(default, skip_serializing_if = "ProviderConnectionOverrides::is_empty")]
pub providers: ProviderConnectionOverrides,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[schemars(length(min = 1))]
pub prompts: Vec<PromptSource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcps: Vec<McpSourceSpec>,
#[serde(default, skip_serializing_if = "ToolFilter::is_empty")]
pub tools: ToolFilter,
}
fn require_agent_invocation_surface_schema(schema: &mut schemars::Schema) {
let Some(mut base_schema) = schema.as_object().cloned() else {
return;
};
let description = base_schema.remove("description");
let invocation_surface_schema = serde_json::json!({
"anyOf": [
{
"type": "object",
"additionalProperties": false,
"required": ["userInvocable"],
"properties": { "userInvocable": { "const": true } }
},
{
"type": "object",
"additionalProperties": false,
"required": ["agentInvocable"],
"properties": { "agentInvocable": { "const": true } }
}
]
});
let mut composed_schema = serde_json::Map::new();
if let Some(description) = description {
composed_schema.insert("description".to_string(), description);
}
composed_schema.insert("allOf".to_string(), serde_json::json!([base_schema, invocation_surface_schema]));
*schema = composed_schema.into();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn optional_reasoning_preserves_absence_and_explicit_default() {
let base = serde_json::json!({"name": "test", "description": "test", "model": "openai:gpt-5.4", "userInvocable": true});
let omitted: AgentConfig = serde_json::from_value(base.clone()).unwrap();
assert_eq!(omitted.reasoning_effort, None);
assert!(serde_json::to_value(omitted).unwrap().get("reasoningEffort").is_none());
for (value, expected) in
[(serde_json::Value::Null, None), (serde_json::json!("default"), Some(ReasoningEffort::Default))]
{
let mut input = base.clone();
input["reasoningEffort"] = value;
let config: AgentConfig = serde_json::from_value(input).unwrap();
assert_eq!(config.reasoning_effort, expected);
if expected.is_some() {
assert_eq!(serde_json::to_value(config).unwrap()["reasoningEffort"], "default");
}
}
}
}