Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/orchestral-cli/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,15 +303,15 @@ async fn build_agent_host_with_journals(
.with_context(|| format!("load Generic Agent config '{}'", config_path.display()))?;
let (backend, profile, model, temperature) = resolve_model(&config).await?;
let reasoning =
crate::model_controls::resolve_reasoning(config.agent.reasoning, profile.as_ref())?;
crate::model_controls::resolve_reasoning(config.agent.reasoning.clone(), profile.as_ref())?;
let (model_backend, token_meter) = build_model_backend(
&backend,
&model,
temperature,
profile.as_ref(),
config.agent.stream_buffer,
options.credential_file.as_deref(),
reasoning,
reasoning.clone(),
)?;

let mut agent_config = GenericAgentConfig::new("orchestral/internal", "generic-agent");
Expand Down Expand Up @@ -423,7 +423,7 @@ async fn build_agent_host_with_journals(
agent_config.project_instructions.iter().map(|doc| format!("{}\n Scope: {}", doc.source, doc.scope)).collect::<Vec<_>>().join("\n")),
models: config.providers.models.clone(),
model_backend: backend.clone(),
reasoning,
reasoning: reasoning.clone(),
};
let session_history = if matches!(config.journal.backend.as_str(), "fs" | "filesystem") {
crate::local_sessions::LocalSessionHistory::Directory(base_journal_root)
Expand Down
17 changes: 9 additions & 8 deletions apps/orchestral-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,8 @@ pub struct Cli {
/// Override the model's sampling temperature.
#[arg(long, global = true)]
temperature: Option<f32>,
/// Generic Agent reasoning: default omits controls; none disables effort; on/off toggle thinking.
#[arg(
long,
value_name = "default|none|minimal|low|medium|high|xhigh|max|on|off",
global = true
)]
/// Generic Agent reasoning: default omits controls; on/off toggle thinking; other names request provider effort. Use effort:<name> for literal reserved names.
#[arg(long, value_name = "default|on|off|EFFORT|effort:NAME", global = true)]
reasoning: Option<orchestral_core::config::ReasoningPreference>,
/// Continue the conversation with this session ID
#[arg(long, global = true)]
Expand Down Expand Up @@ -125,7 +121,7 @@ impl Cli {
model_profile: self.model_profile.clone(),
model,
temperature: self.temperature,
reasoning: self.reasoning,
reasoning: self.reasoning.clone(),
base_url,
api_key_env: self.api_key_env.clone(),
no_auth: self.no_auth,
Expand Down Expand Up @@ -223,7 +219,12 @@ mod tests {
.reasoning,
Some(ReasoningPreference::Default)
);
assert!(Cli::try_parse_from(["orchestral", "--reasoning", "ultra", "inspect"]).is_err());
assert_eq!(
Cli::try_parse_from(["orchestral", "--reasoning", "ultra", "inspect"])
.unwrap()
.reasoning,
Some(ReasoningPreference::Custom("ultra".into()))
);
let native =
Cli::try_parse_from(["orchestral", "--reasoning", "high", "sessions", "agents"])
.unwrap();
Expand Down
4 changes: 4 additions & 0 deletions apps/orchestral-cli/src/model_controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ pub(crate) fn openai_reasoning(
Preference::High => OpenAiReasoningControl::Effort(Effort::High),
Preference::XHigh => OpenAiReasoningControl::Effort(Effort::XHigh),
Preference::Max => OpenAiReasoningControl::Effort(Effort::Max),
Preference::Custom(value) => {
OpenAiReasoningControl::Effort(value.parse().map_err(anyhow::Error::msg)?)
}
Preference::Default => unreachable!(),
}))
}
Expand All @@ -75,6 +78,7 @@ pub(crate) fn discovered_model(model: orchestral_model_openai::DiscoveredModel)
Effort::High => Preference::High,
Effort::XHigh => Preference::XHigh,
Effort::Max => Preference::Max,
Effort::Custom(value) => Preference::Custom(value),
}));
}
if caps.thinking.is_some() {
Expand Down
11 changes: 11 additions & 0 deletions apps/orchestral-cli/src/model_controls/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ fn profile_reasoning_and_explicit_default_have_distinct_precedence() {
ReasoningPreference::Default
);
let mut invalid = profile.clone();
for (value, expected) in [
("future-next", "future-next"),
("HIGH", "HIGH"),
("effort:off", "off"),
] {
invalid.config["reasoning"] = json!(value);
assert_eq!(
resolve_reasoning(None, Some(&invalid)).unwrap(),
ReasoningPreference::Custom(expected.into())
);
}
invalid.config["reasoning"] = json!(true);
assert!(resolve_reasoning(None, Some(&invalid)).is_err());
}
Expand Down
11 changes: 8 additions & 3 deletions apps/orchestral-cli/src/runtime/client/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ fn merge_model_overrides(requested: &ModelOverrides, automatic: &ModelOverrides)
.or_else(|| automatic.model_profile.clone()),
model: requested.model.clone().or_else(|| automatic.model.clone()),
temperature: requested.temperature.or(automatic.temperature),
reasoning: requested.reasoning.or(automatic.reasoning),
reasoning: requested
.reasoning
.clone()
.or_else(|| automatic.reasoning.clone()),
base_url: requested.base_url.clone(),
api_key_env: requested.api_key_env.clone(),
no_auth: requested.no_auth,
Expand Down Expand Up @@ -211,7 +214,7 @@ fn apply_model_overrides_to_yaml(
serde_yaml::to_value(temperature).context("serialize temperature")?,
);
}
if let Some(reasoning) = overrides.reasoning {
if let Some(reasoning) = &overrides.reasoning {
set_yaml_key(agent, "reasoning", serde_yaml::to_value(reasoning)?);
}
if overrides.base_url.is_some() || overrides.api_key_env.is_some() || overrides.no_auth {
Expand Down Expand Up @@ -557,14 +560,16 @@ mod tests {
ReasoningPreference::Off,
ReasoningPreference::None,
ReasoningPreference::Default,
ReasoningPreference::Custom("future-next".into()),
ReasoningPreference::Custom("on".into()),
] {
let mut yaml: YamlValue = serde_yaml::from_str(&raw).unwrap();
apply_model_overrides_to_yaml(
&mut yaml,
&config,
&ModelOverrides {
model: Some("selected-api-model".into()),
reasoning: Some(reasoning),
reasoning: Some(reasoning.clone()),
..Default::default()
},
)
Expand Down
2 changes: 1 addition & 1 deletion apps/orchestral-cli/src/tui/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ pub(crate) const COMMANDS: &[(&str, &str, &str)] = &[
(
"/reasoning",
"Change reasoning",
"Inspect the current model's reasoning controls for the next request",
"Inspect declared controls, or /reasoning <value>; effort:<name> sends a literal effort",
),
(
"/copy",
Expand Down
43 changes: 28 additions & 15 deletions apps/orchestral-cli/src/tui/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,18 +131,31 @@ pub(super) async fn reasoning(metadata: &HostMetadata, current: &str) -> Result<
}

fn reasoning_choices(model: &DiscoveredModel) -> Vec<ReasoningPreference> {
use ReasoningPreference as R;
model.reasoning.clone().unwrap_or_else(|| {
vec![
R::Default,
R::None,
R::Minimal,
R::Low,
R::Medium,
R::High,
R::XHigh,
R::Max,
]
model
.reasoning
.clone()
.unwrap_or_else(|| vec![ReasoningPreference::Default])
}

pub(super) fn reasoning_argument(command: &str) -> Option<&str> {
let rest = command.strip_prefix("/reasoning")?;
rest.starts_with(char::is_whitespace).then(|| rest.trim())
}

pub(super) fn explicit_reasoning(
metadata: &HostMetadata,
current: &str,
value: &str,
) -> Result<Selection> {
let value = value
.parse::<ReasoningPreference>()
.map_err(anyhow::Error::msg)?;
// Validate protocol support without requiring optional model discovery.
crate::model_controls::openai_reasoning(&metadata.model_backend, value.clone())?;
Ok(Selection::Reasoning {
backend: metadata.model_backend.name.clone(),
model: current.to_owned(),
value,
})
}

Expand All @@ -154,7 +167,7 @@ fn reasoning_menu(metadata: &HostMetadata, model: &DiscoveredModel) -> Menu {
reasoning_choices(model)
.into_iter()
.map(|value| {
let description = match value {
let description = match &value {
ReasoningPreference::Default
if model.thinking_default_enabled == Some(true) =>
{
Expand All @@ -166,7 +179,7 @@ fn reasoning_menu(metadata: &HostMetadata, model: &DiscoveredModel) -> Menu {
"Use service default (thinking off); omit controls"
}
ReasoningPreference::Default if !declared => {
"Capabilities not declared; omit controls by default"
"Capabilities not declared; use /reasoning <value> or --reasoning for the service to validate an explicit request"
}
ReasoningPreference::Default => "Use service default; omit reasoning controls",
ReasoningPreference::On => {
Expand All @@ -179,7 +192,7 @@ fn reasoning_menu(metadata: &HostMetadata, model: &DiscoveredModel) -> Menu {
}
};
choice(
value.as_str(),
&value.to_string(),
Selection::Reasoning {
backend: metadata.model_backend.name.clone(),
model: model.id.clone(),
Expand Down
70 changes: 65 additions & 5 deletions apps/orchestral-cli/src/tui/models/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,69 @@ fn binary_reasoning_menu_lists_only_default_on_off_and_marks_unknown_explicitly(
"selectable choices must not become a read-only detail panel"
);
assert!(menu.choices[0].description.contains("not declared"));
assert!(menu
.choices
.iter()
.filter(|choice| choice.label != "default")
.all(|choice| choice.description.contains("validated by the service")));
assert_eq!(menu.choices.len(), 1);
assert!(menu.choices[0].description.contains("/reasoning <value>"));
}

#[test]
fn declared_efforts_round_trip_through_menu_and_selection_without_local_control_aliases() {
let metadata = metadata("http://127.0.0.1:1/v1");
for (name, label) in [
("ultra", "ultra"),
("future-next", "future-next"),
("HIGH", "HIGH"),
("on", "effort:on"),
("off", "effort:off"),
("default", "effort:default"),
("effort:on", "effort:effort:on"),
] {
let model =
crate::model_controls::discovered_model(orchestral_model_openai::DiscoveredModel {
id: "model".into(),
max_context_tokens: None,
reasoning: Some(orchestral_model_openai::OpenAiReasoningCapabilities {
supported_efforts: Some(vec![name.parse().unwrap()]),
thinking: None,
}),
});
let menu = reasoning_menu(&metadata, &model);
assert_eq!(menu.choices.len(), 2);
assert_eq!(menu.choices[1].label, label);
let selection = serde_json::from_str(&menu.choices[1].value).unwrap();
let mut overrides = ModelOverrides::default();
apply_selection(&metadata, "model", &mut overrides, selection).unwrap();
let control = crate::model_controls::openai_reasoning(
&metadata.model_backend,
overrides.reasoning.unwrap(),
)
.unwrap()
.unwrap();
assert_eq!(
control,
orchestral_model_openai::OpenAiReasoningControl::Effort(name.parse().unwrap())
);
}
}

#[test]
fn explicit_reasoning_command_needs_no_discovery_and_preserves_custom_effort() {
let metadata = metadata("http://127.0.0.1:1/v1");
for (command, expected) in [
("/reasoning future-next", "future-next"),
("/reasoning\tHIGH", "HIGH"),
("/reasoning effort:on", "on"),
] {
let argument = reasoning_argument(command).unwrap();
let selection = explicit_reasoning(&metadata, "model", argument).unwrap();
let mut overrides = ModelOverrides::default();
apply_selection(&metadata, "model", &mut overrides, selection).unwrap();
assert_eq!(overrides.model.as_deref(), Some("model"));
assert_eq!(
overrides.reasoning,
Some(ReasoningPreference::Custom(expected.into()))
);
}
assert!(reasoning_argument("/reasoning").is_none());
assert!(reasoning_argument("/reasoningful high").is_none());
assert!(explicit_reasoning(&metadata, "model", "effort:").is_err());
}
15 changes: 14 additions & 1 deletion apps/orchestral-cli/src/tui/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ impl Tasks {
if (matches!(
name.as_str(),
"/model" | "/model profiles" | "/reasoning" | "/new"
) || matches!(selection, Some((MenuKind::Models | MenuKind::Sessions, _))))
) || super::models::reasoning_argument(&name).is_some()
|| matches!(selection, Some((MenuKind::Models | MenuKind::Sessions, _))))
&& active
{
state.ui_notice = Some(
Expand Down Expand Up @@ -156,6 +157,7 @@ impl Tasks {
self.generation = self.generation.wrapping_add(1);
let generation = self.generation;
let changes_host_state = name == "/new"
|| super::models::reasoning_argument(&name).is_some()
|| matches!(selection, Some((MenuKind::Models | MenuKind::Sessions, _)))
|| matches!(action, Some(LocalAction::SetSkillEnabled { .. }))
|| (name.starts_with("/skills ") && name.trim() != "/skills list");
Expand Down Expand Up @@ -317,6 +319,17 @@ pub(crate) async fn command(
"/reasoning" => Ok(Response::Menu(
super::models::reasoning(&host.metadata, &host.model).await?,
)),
command if super::models::reasoning_argument(command).is_some() => {
let value = super::models::reasoning_argument(command).unwrap();
let selection = super::models::explicit_reasoning(&host.metadata, &host.model, value)?;
choose(
host,
options,
MenuKind::Models,
serde_json::to_string(&selection)?,
)
.await
}
"/resume" => {
let sessions = host
.session_history
Expand Down
Loading
Loading