feat(core) ModelInfo.model_instructions_template (#9597)

## Summary
#9555 is the start of a rename, so I'm starting to standardize here.
Sets up `model_instructions` templating with a strongly-typed object for
injecting a personality block into the model instructions.

## Testing
- [x] Added tests
- [x] Ran locally
This commit is contained in:
Dylan Hurd
2026-01-21 18:11:18 -08:00
committed by GitHub
parent a489b64cb5
commit 96a72828be
15 changed files with 232 additions and 42 deletions

View File

@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -7,10 +8,14 @@ use serde::Serialize;
use strum::IntoEnumIterator;
use strum_macros::Display;
use strum_macros::EnumIter;
use tracing::warn;
use ts_rs::TS;
use crate::config_types::Personality;
use crate::config_types::Verbosity;
const PERSONALITY_PLACEHOLDER: &str = "{{ personality_message }}";
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(
Debug,
@@ -180,6 +185,8 @@ pub struct ModelInfo {
pub priority: i32,
pub upgrade: Option<ModelInfoUpgrade>,
pub base_instructions: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_instructions_template: Option<ModelInstructionsTemplate>,
pub supports_reasoning_summaries: bool,
pub support_verbosity: bool,
pub default_verbosity: Option<Verbosity>,
@@ -206,8 +213,49 @@ impl ModelInfo {
.map(|context_window| (context_window * 9) / 10)
})
}
pub fn get_model_instructions(&self, personality: Option<Personality>) -> String {
if let Some(personality) = personality
&& let Some(template) = &self.model_instructions_template
&& template.has_personality_placeholder()
&& let Some(personality_messages) = &template.personality_messages
&& let Some(personality_message) = personality_messages.0.get(&personality)
{
template
.template
.replace(PERSONALITY_PLACEHOLDER, personality_message.as_str())
} else if let Some(personality) = personality {
warn!(
model = %self.slug,
%personality,
"Model personality requested but model_instructions_template is invalid, falling back to base instructions."
);
self.base_instructions.clone()
} else {
self.base_instructions.clone()
}
}
}
/// A strongly-typed template for assembling model instructions. If populated and valid, will override
/// base_instructions.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelInstructionsTemplate {
pub template: String,
pub personality_messages: Option<PersonalityMessages>,
}
impl ModelInstructionsTemplate {
fn has_personality_placeholder(&self) -> bool {
self.template.contains(PERSONALITY_PLACEHOLDER)
}
}
// serializes as a dictionary from personality to message
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS, JsonSchema)]
#[serde(transparent)]
pub struct PersonalityMessages(pub BTreeMap<Personality, String>);
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelInfoUpgrade {
pub model: String,
@@ -335,3 +383,67 @@ fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> Rea
.min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs())
.unwrap_or(target)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn test_model(template: Option<ModelInstructionsTemplate>) -> ModelInfo {
ModelInfo {
slug: "test-model".to_string(),
display_name: "Test Model".to_string(),
description: None,
default_reasoning_level: None,
supported_reasoning_levels: vec![],
shell_type: ConfigShellToolType::ShellCommand,
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
upgrade: None,
base_instructions: "base".to_string(),
model_instructions_template: template,
supports_reasoning_summaries: false,
support_verbosity: false,
default_verbosity: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
context_window: None,
auto_compact_token_limit: None,
effective_context_window_percent: 95,
experimental_supported_tools: vec![],
}
}
fn personality_messages() -> PersonalityMessages {
PersonalityMessages(BTreeMap::from([(
Personality::Friendly,
"friendly".to_string(),
)]))
}
#[test]
fn get_model_instructions_uses_template_when_placeholder_present() {
let model = test_model(Some(ModelInstructionsTemplate {
template: "Hello {{ personality_message }}".to_string(),
personality_messages: Some(personality_messages()),
}));
let instructions = model.get_model_instructions(Some(Personality::Friendly));
assert_eq!(instructions, "Hello friendly");
}
#[test]
fn get_model_instructions_falls_back_when_placeholder_missing() {
let model = test_model(Some(ModelInstructionsTemplate {
template: "Hello there".to_string(),
personality_messages: Some(personality_messages()),
}));
let instructions = model.get_model_instructions(Some(Personality::Friendly));
assert_eq!(instructions, "base");
}
}