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
110 changes: 110 additions & 0 deletions crates/agent-runtime/src/claude/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,48 @@ pub fn permission_modes() -> Vec<crate::runtime::SessionMode> {
]
}

/// The models offered, as aliases rather than pinned identifiers.
///
/// `claude --help` documents these as "an alias for the latest model", so the CLI
/// resolves each to whatever is current. Pinning `claude-opus-4-1-20250805` here
/// would mean shipping a list that silently rots: every new model would need a
/// Tervin release, and worse, a stale entry names a model that still exists and so
/// fails by quietly running the wrong one rather than by erroring.
///
/// The resolved name is reported back by the session and shown alongside, because
/// the alias is not what runs and the difference is what costs money.
pub fn model_choices() -> Vec<crate::runtime::LaunchChoice> {
use crate::runtime::LaunchChoice;
vec![
LaunchChoice::new("", "Profile default")
.with_note("Whatever the profile or the CLI's own configuration selects."),
LaunchChoice::new("opus", "Opus").with_note("Most capable, and the most expensive."),
LaunchChoice::new("sonnet", "Sonnet").with_note("The general-purpose balance."),
LaunchChoice::new("fable", "Fable"),
LaunchChoice::new("haiku", "Haiku").with_note("Fastest and cheapest."),
]
}

/// The reasoning-effort levels the CLI accepts.
///
/// Unlike the models, this list has to be exact. An unrecognised `--effort` value
/// is a *warning*, not an error: the CLI prints one line, falls back to the default
/// and runs anyway. A typo would therefore produce a session that looks like it is
/// running at the requested effort and is not, which is precisely the class of
/// silent mismatch Tervin exists to make visible. These five are the values the
/// shipped binary names when it rejects one.
pub fn effort_choices() -> Vec<crate::runtime::LaunchChoice> {
use crate::runtime::LaunchChoice;
vec![
LaunchChoice::new("", "Default effort"),
LaunchChoice::new("low", "Low").with_note("Least thinking, least cost."),
LaunchChoice::new("medium", "Medium"),
LaunchChoice::new("high", "High"),
LaunchChoice::new("xhigh", "Extra high"),
LaunchChoice::new("max", "Max").with_note("Most thinking, and the slowest."),
]
}

/// Shared state between the session handle and its reader task.
struct Shared {
normalizer: Mutex<Normalizer>,
Expand Down Expand Up @@ -300,6 +342,10 @@ impl ClaudeCodeRuntime {
args.push("--model".into());
args.push(model.clone());
}
if let Some(effort) = &config.effort {
args.push("--effort".into());
args.push(effort.clone());
}
let mode = config
.permission_mode
.clone()
Expand Down Expand Up @@ -542,6 +588,13 @@ impl AgentRuntime for ClaudeCodeRuntime {
Self::static_capabilities()
}

fn launch_options(&self) -> crate::runtime::LaunchOptions {
crate::runtime::LaunchOptions {
models: model_choices(),
efforts: effort_choices(),
}
}

async fn launch(&self, config: LaunchConfig) -> Result<LaunchedSession> {
self.start(config, None).await
}
Expand Down Expand Up @@ -1077,6 +1130,63 @@ mod tests {
assert_eq!(args[i + 1], "abc-123");
}

#[test]
fn model_and_effort_are_passed_only_when_chosen() {
let rt = ClaudeCodeRuntime::new();

// Nothing chosen: neither flag appears, so the CLI's own configuration and
// the user's defaults decide. Passing an empty value would override them.
let bare = rt.build_args(&config(), None, None);
assert!(!bare.iter().any(|a| a == "--model"));
assert!(!bare.iter().any(|a| a == "--effort"));

let mut cfg = config();
cfg.model = Some("opus".into());
cfg.effort = Some("high".into());
let args = rt.build_args(&cfg, None, None);
let m = args
.iter()
.position(|a| a == "--model")
.expect("no --model");
assert_eq!(args[m + 1], "opus");
let e = args
.iter()
.position(|a| a == "--effort")
.expect("no --effort");
assert_eq!(args[e + 1], "high");
}

#[test]
fn the_offered_efforts_are_exactly_the_ones_the_cli_accepts() {
// This list has to be exact in a way the model list does not. An
// unrecognised `--effort` value is a warning, not an error: the CLI falls
// back to the default and runs anyway, so a wrong entry here produces a
// session that reports one effort and spends another. These five are what
// the binary names when it rejects a value.
let offered: Vec<String> = effort_choices()
.into_iter()
.map(|c| c.value)
.filter(|v| !v.is_empty())
.collect();
assert_eq!(offered, ["low", "medium", "high", "xhigh", "max"]);
}

#[test]
fn the_offered_models_are_aliases_rather_than_pinned_identifiers() {
// A pinned id fails by quietly running last year's model, since the old name
// still resolves. An alias is documented to track whatever is current, so
// the list cannot rot into silently wrong.
for choice in model_choices() {
assert!(
!choice.value.starts_with("claude-"),
"{} is a pinned identifier, not an alias",
choice.value
);
}
let values: Vec<String> = model_choices().into_iter().map(|c| c.value).collect();
assert!(values.contains(&String::new()), "no way to express 'unset'");
}

#[test]
fn policy_is_pushed_down_to_the_runtime() {
// Rules must apply before an action runs, not only after Tervin sees it.
Expand Down
5 changes: 3 additions & 2 deletions crates/agent-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ pub use mcp::{McpConfig, McpServer};
pub use profile::{AgentProfile, ImportCandidate, ProfileConfig};
pub use registry::{RuntimeRegistry, GENERIC_AGENTS};
pub use runtime::{
AgentRuntime, AgentSession, ArbiterDecision, Attachment, Discovery, LaunchConfig,
LaunchedSession, PermissionArbiter, PermissionState, RuntimeError, SessionMetadata,
AgentRuntime, AgentSession, ArbiterDecision, Attachment, Discovery, LaunchChoice, LaunchConfig,
LaunchOptions, LaunchedSession, PermissionArbiter, PermissionState, RuntimeError,
SessionMetadata,
};

/// Resolve a binary on `PATH`.
Expand Down
50 changes: 50 additions & 0 deletions crates/agent-runtime/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ pub struct LaunchConfig {
pub attachments: Vec<Attachment>,
/// Runtime-specific model selector, when the runtime supports choosing.
pub model: Option<String>,
/// Runtime-specific reasoning effort, when the runtime supports choosing.
pub effort: Option<String>,
/// Runtime-specific permission mode.
pub permission_mode: Option<String>,
/// Tool patterns Tervin Rules pre-authorises, passed to the runtime so policy
Expand Down Expand Up @@ -172,6 +174,7 @@ impl LaunchConfig {
prompt: None,
attachments: Vec::new(),
model: None,
effort: None,
permission_mode: None,
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
Expand Down Expand Up @@ -324,13 +327,60 @@ pub trait AgentRuntime: Send + Sync {
/// Static capability declaration, refined by `discover` and by a live session.
fn capabilities(&self) -> Capabilities;

/// The launch choices this runtime accepts, for controls shown *before* a
/// session exists.
///
/// Separate from the modes a live session reports, because the composer has to
/// offer these when there is nothing running to ask. Declared by the adapter
/// rather than listed in the UI for the same reason the mode picker is: an
/// interface offering a choice the runtime would reject is worse than one
/// offering none. A runtime that takes neither returns the default and the
/// controls do not appear at all.
fn launch_options(&self) -> LaunchOptions {
LaunchOptions::default()
}

/// Start a new session.
async fn launch(&self, config: LaunchConfig) -> Result<LaunchedSession>;

/// Continue a previous session by its runtime-issued id.
async fn resume(&self, resume_id: &str, config: LaunchConfig) -> Result<LaunchedSession>;
}

/// One option in a launch control, as the adapter defines it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchChoice {
/// What is passed to the runtime verbatim.
pub value: String,
/// What the picker shows.
pub label: String,
/// A caveat worth reading before choosing, such as what it costs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}

impl LaunchChoice {
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
note: None,
}
}

pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
}

/// What a runtime accepts at launch. Empty means the control is not shown.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LaunchOptions {
pub models: Vec<LaunchChoice>,
pub efforts: Vec<LaunchChoice>,
}

/// A session plus the event stream it produces.
///
/// The receiver is the `event_stream()` of the specified interface. It is handed
Expand Down
27 changes: 26 additions & 1 deletion crates/tervin-app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,13 @@ pub async fn audit_recent(
pub struct AgentsOverview {
pub profiles: Vec<AgentProfile>,
pub default_profile: Option<String>,
/// What each runtime accepts at launch, keyed by runtime id.
///
/// Carried here rather than with discovery because the composer draws these
/// controls before anything has been probed, and a control that only appears
/// once a subprocess finishes is one the user will not find. It is also free:
/// every adapter declares this statically.
pub launch_options: std::collections::BTreeMap<String, agent_runtime::LaunchOptions>,
/// Where the files the UI mentions actually are.
///
/// Resolved rather than written into the interface, because the location differs by
Expand All @@ -816,9 +823,19 @@ pub struct AgentsDiscovery {
#[tauri::command]
pub async fn agents_overview(state: State<'_, Arc<AppState>>) -> Result<AgentsOverview> {
let profiles = state.profiles.read().clone();
// Static declarations, so this stays a lock and a map with nothing spawned.
let launch_options = state
.agents
.read()
.snapshot()
.into_iter()
.map(|a| (a.runtime_id().to_string(), a.launch_options()))
.collect();

Ok(AgentsOverview {
default_profile: profiles.default_profile,
profiles: profiles.profiles,
launch_options,
profiles_path: tervin_core::paths::abbreviate(&ProfileConfig::path()),
mcp_path: tervin_core::paths::abbreviate(&agent_runtime::McpConfig::path()),
})
Expand Down Expand Up @@ -1063,6 +1080,8 @@ pub struct ThreadStartRequest {
#[serde(default)]
pub attachments: Vec<Attachment>,
pub model: Option<String>,
/// Reasoning effort, where the runtime offers it.
pub effort: Option<String>,
pub permission_mode: Option<String>,
pub task_title: Option<String>,
/// Resume a previous session by its runtime-issued id.
Expand Down Expand Up @@ -1111,7 +1130,13 @@ pub async fn thread_start(
let mut config = LaunchConfig::new(thread_id.clone(), cwd.clone());
config.prompt = Some(request.prompt.clone());
config.attachments = request.attachments;
config.model = request.model.or_else(|| profile.model.clone());
// An empty selection means "whatever the profile or the CLI already chooses",
// which is not the same as passing an empty flag value.
config.model = request
.model
.filter(|m| !m.trim().is_empty())
.or_else(|| profile.model.clone());
config.effort = request.effort.filter(|e| !e.trim().is_empty());
config.permission_mode = request
.permission_mode
.or_else(|| profile.permission_mode.clone());
Expand Down
Loading