Skip to main content

aether_project/
agent_config.rs

1use crate::{McpSourceSpec, PromptSource};
2use llm::{ModelSettings, ProviderConnectionOverrides, ReasoningEffort};
3use mcp_utils::client::ToolFilter;
4
5#[doc = include_str!("docs/agent_config.md")]
6#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
7#[serde(rename_all = "camelCase", deny_unknown_fields)]
8#[schemars(transform = require_agent_invocation_surface_schema)]
9pub struct AgentConfig {
10    /// Agent identifier. Names are trimmed for merge and lookup.
11    #[schemars(length(min = 1))]
12    pub name: String,
13    /// Human-readable description shown in UIs and sub-agent listings.
14    #[schemars(length(min = 1))]
15    pub description: String,
16    /// Model spec for this agent, in `provider:model-id` form. Accepts a
17    /// comma-separated alloy of specs to round-robin across turns.
18    #[schemars(length(min = 1))]
19    pub model: String,
20    /// Reasoning level for the LLM. Uses provider default when not explicitly set.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub reasoning_effort: Option<ReasoningEffort>,
23    /// Sampling controls (`temperature`, `topP`, `maxTokens`) applied to this
24    /// agent's model calls. Omitted knobs keep the provider/model default.
25    #[serde(default, skip_serializing_if = "ModelSettings::is_empty")]
26    pub model_settings: ModelSettings,
27    /// Override for the model's context window, in tokens. Defaults to the
28    /// model's advertised window.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    #[schemars(range(min = 1))]
31    pub context_window: Option<u32>,
32    /// Exposes the agent as a user-selectable mode.
33    #[serde(default)]
34    pub user_invocable: bool,
35    /// Exposes the agent to the `subagents` MCP server so other agents can spawn it.
36    #[serde(default)]
37    pub agent_invocable: bool,
38    /// Per-agent provider overrides, merged over the top-level `providers`.
39    #[serde(default, skip_serializing_if = "ProviderConnectionOverrides::is_empty")]
40    pub providers: ProviderConnectionOverrides,
41    /// Agent-specific prompt sources. A non-empty array replaces the top-level `prompts`.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    #[schemars(length(min = 1))]
44    pub prompts: Vec<PromptSource>,
45    /// Agent-specific MCP sources. A non-empty array replaces the top-level `mcps`.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub mcps: Vec<McpSourceSpec>,
48    /// Per-agent MCP tool filter (allow/deny lists).
49    #[serde(default, skip_serializing_if = "ToolFilter::is_empty")]
50    pub tools: ToolFilter,
51}
52
53fn require_agent_invocation_surface_schema(schema: &mut schemars::Schema) {
54    let Some(mut base_schema) = schema.as_object().cloned() else {
55        return;
56    };
57    let description = base_schema.remove("description");
58
59    let invocation_surface_schema = serde_json::json!({
60        "anyOf": [
61            {
62                "type": "object",
63                "additionalProperties": false,
64                "required": ["userInvocable"],
65                "properties": { "userInvocable": { "const": true } }
66            },
67            {
68                "type": "object",
69                "additionalProperties": false,
70                "required": ["agentInvocable"],
71                "properties": { "agentInvocable": { "const": true } }
72            }
73        ]
74    });
75
76    let mut composed_schema = serde_json::Map::new();
77    if let Some(description) = description {
78        composed_schema.insert("description".to_string(), description);
79    }
80    composed_schema.insert("allOf".to_string(), serde_json::json!([base_schema, invocation_surface_schema]));
81    *schema = composed_schema.into();
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn optional_reasoning_preserves_absence_and_explicit_default() {
90        let base = serde_json::json!({"name": "test", "description": "test", "model": "openai:gpt-5.4", "userInvocable": true});
91        let omitted: AgentConfig = serde_json::from_value(base.clone()).unwrap();
92        assert_eq!(omitted.reasoning_effort, None);
93        assert!(serde_json::to_value(omitted).unwrap().get("reasoningEffort").is_none());
94        for (value, expected) in
95            [(serde_json::Value::Null, None), (serde_json::json!("default"), Some(ReasoningEffort::Default))]
96        {
97            let mut input = base.clone();
98            input["reasoningEffort"] = value;
99            let config: AgentConfig = serde_json::from_value(input).unwrap();
100            assert_eq!(config.reasoning_effort, expected);
101            if expected.is_some() {
102                assert_eq!(serde_json::to_value(config).unwrap()["reasoningEffort"], "default");
103            }
104        }
105    }
106}