From d8ccb88b0b4cc874fbf6d4e0e381f6bf897969d4 Mon Sep 17 00:00:00 2001 From: Matt Lee Date: Mon, 8 Sep 2025 19:33:42 -0400 Subject: [PATCH] feat: add model field support to agent format - Add optional 'model' field to Agent struct for specifying model per agent - Update JSON schema and documentation with model field usage - Integrate agent model into model selection priority: 1. CLI argument (--model) 2. Agent's model field (new) 3. User's saved default model 4. System default model - Add proper fallback when agent specifies unavailable model - Extract fallback logic to eliminate code duplication - Include comprehensive unit tests for model field functionality - Maintain backward compatibility with existing agent configurations --- crates/chat-cli/src/cli/agent/mod.rs | 63 ++++++++++++++++++++++++++++ crates/chat-cli/src/cli/chat/mod.rs | 36 +++++++++++++--- docs/agent-format.md | 18 +++++++- schemas/agent-v1.json | 8 ++++ 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/chat-cli/src/cli/agent/mod.rs b/crates/chat-cli/src/cli/agent/mod.rs index 032891705d..bf69dcba03 100644 --- a/crates/chat-cli/src/cli/agent/mod.rs +++ b/crates/chat-cli/src/cli/agent/mod.rs @@ -161,6 +161,9 @@ pub struct Agent { /// you configure in the mcpServers field in this config #[serde(default)] pub use_legacy_mcp_json: bool, + /// The model ID to use for this agent. If not specified, uses the default model. + #[serde(default)] + pub model: Option, #[serde(skip)] pub path: Option, } @@ -188,6 +191,7 @@ impl Default for Agent { hooks: Default::default(), tools_settings: Default::default(), use_legacy_mcp_json: true, + model: None, path: None, } } @@ -1215,6 +1219,7 @@ mod tests { resources: Vec::new(), hooks: Default::default(), use_legacy_mcp_json: false, + model: None, path: None, }; @@ -1285,4 +1290,62 @@ mod tests { label ); } + + #[test] + fn test_agent_model_field() { + // Test deserialization with model field + let agent_json = r#"{ + "name": "test-agent", + "model": "claude-sonnet-4" + }"#; + + let agent: Agent = serde_json::from_str(agent_json).expect("Failed to deserialize agent with model"); + assert_eq!(agent.model, Some("claude-sonnet-4".to_string())); + + // Test default agent has no model + let default_agent = Agent::default(); + assert_eq!(default_agent.model, None); + + // Test serialization includes model field + let agent_with_model = Agent { + model: Some("test-model".to_string()), + ..Default::default() + }; + let serialized = serde_json::to_string(&agent_with_model).expect("Failed to serialize"); + assert!(serialized.contains("\"model\":\"test-model\"")); + } + + #[test] + fn test_agent_model_fallback_priority() { + // Test that agent model is checked and falls back correctly + let mut agents = Agents::default(); + + // Create agent with unavailable model + let agent_with_invalid_model = Agent { + name: "test-agent".to_string(), + model: Some("unavailable-model".to_string()), + ..Default::default() + }; + + agents.agents.insert("test-agent".to_string(), agent_with_invalid_model); + agents.active_idx = "test-agent".to_string(); + + // Verify the agent has the model set + assert_eq!( + agents.get_active().and_then(|a| a.model.as_ref()), + Some(&"unavailable-model".to_string()) + ); + + // Test agent without model + let agent_without_model = Agent { + name: "no-model-agent".to_string(), + model: None, + ..Default::default() + }; + + agents.agents.insert("no-model-agent".to_string(), agent_without_model); + agents.active_idx = "no-model-agent".to_string(); + + assert_eq!(agents.get_active().and_then(|a| a.model.as_ref()), None); + } } diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 43014bb857..d92c5f44f6 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -44,6 +44,7 @@ use cli::compact::CompactStrategy; use cli::model::{ get_available_models, select_model, + find_model, }; pub use conversation::ConversationState; use conversation::TokenWarningLevel; @@ -141,7 +142,6 @@ use crate::cli::TodoListState; use crate::cli::agent::Agents; use crate::cli::chat::cli::SlashCommand; use crate::cli::chat::cli::editor::open_editor; -use crate::cli::chat::cli::model::find_model; use crate::cli::chat::cli::prompts::{ GetPromptError, PromptsSubcommand, @@ -340,7 +340,17 @@ impl ChatArgs { // If modelId is specified, verify it exists before starting the chat // Otherwise, CLI will use a default model when starting chat let (models, default_model_opt) = get_available_models(os).await?; + // Fallback logic: try user's saved default, then system default + let fallback_model_id = || { + if let Some(saved) = os.database.settings.get_string(Setting::ChatDefaultModel) { + find_model(&models, &saved).map(|m| m.model_id.clone()).or(Some(default_model_opt.model_id.clone())) + } else { + Some(default_model_opt.model_id.clone()) + } + }; + let model_id: Option = if let Some(requested) = self.model.as_ref() { + // CLI argument takes highest priority if let Some(m) = find_model(&models, requested) { Some(m.model_id.clone()) } else { @@ -351,12 +361,26 @@ impl ChatArgs { .join(", "); bail!("Model '{}' does not exist. Available models: {}", requested, available); } - } else if let Some(saved) = os.database.settings.get_string(Setting::ChatDefaultModel) { - find_model(&models, &saved) - .map(|m| m.model_id.clone()) - .or(Some(default_model_opt.model_id.clone())) + } else if let Some(agent_model) = agents.get_active().and_then(|a| a.model.as_ref()) { + // Agent model takes second priority + if let Some(m) = find_model(&models, agent_model) { + Some(m.model_id.clone()) + } else { + let _ = execute!( + stderr, + style::SetForegroundColor(Color::Yellow), + style::Print("WARNING: "), + style::SetForegroundColor(Color::Reset), + style::Print("Agent specifies model '"), + style::SetForegroundColor(Color::Cyan), + style::Print(agent_model), + style::SetForegroundColor(Color::Reset), + style::Print("' which is not available. Falling back to configured defaults.\n"), + ); + fallback_model_id() + } } else { - Some(default_model_opt.model_id.clone()) + fallback_model_id() }; let (prompt_request_sender, prompt_request_receiver) = tokio::sync::broadcast::channel::(5); diff --git a/docs/agent-format.md b/docs/agent-format.md index c005ad13cd..b467686774 100644 --- a/docs/agent-format.md +++ b/docs/agent-format.md @@ -15,6 +15,7 @@ Every agent configuration file can include the following sections: - [`resources`](#resources-field) — Resources available to the agent. - [`hooks`](#hooks-field) — Commands run at specific trigger points. - [`useLegacyMcpJson`](#uselegacymcpjson-field) — Whether to include legacy MCP configuration. +- [`model`](#model-field) — The model ID to use for this agent. ## Name Field @@ -290,6 +291,20 @@ The `useLegacyMcpJson` field determines whether to include MCP servers defined i When set to `true`, the agent will have access to all MCP servers defined in the global and local configurations in addition to those defined in the agent's `mcpServers` field. +## Model Field + +The `model` field specifies the model ID to use for this agent. If not specified, the agent will use the default model. + +```json +{ + "model": "claude-sonnet-4" +} +``` + +The model ID must match one of the available models returned by the Q CLI's model service. You can see available models by using the `/model` command in an active chat session. + +If the specified model is not available, the agent will fall back to the default model and display a warning. + ## Complete Example Here's a complete example of an agent configuration file: @@ -348,6 +363,7 @@ Here's a complete example of an agent configuration file: } ] }, - "useLegacyMcpJson": true + "useLegacyMcpJson": true, + "model": "claude-sonnet-4" } ``` diff --git a/schemas/agent-v1.json b/schemas/agent-v1.json index 15b626dea8..d49fc53406 100644 --- a/schemas/agent-v1.json +++ b/schemas/agent-v1.json @@ -159,6 +159,14 @@ "description": "Whether or not to include the legacy ~/.aws/amazonq/mcp.json in the agent\nYou can reference tools brought in by these servers as just as you would with the servers\nyou configure in the mcpServers field in this config", "type": "boolean", "default": false + }, + "model": { + "description": "The model ID to use for this agent. If not specified, uses the default model.", + "type": [ + "string", + "null" + ], + "default": null } }, "additionalProperties": false,