feat(config,types): add [run.model.controls] for run-level control defaults

Adds the run-level default controls block from the settings-driven
LLM catalog plan:

  [run.model.controls]
  reasoning_effort = "high"
  speed = "fast"

- RunModelLayer.controls (fabro-config) — sparse layer that field-
  merges and combines through the existing settings stack.
- RunModelControlsLayer (fabro-config) — TOML-facing struct with
  deny_unknown_fields and OptionsMetadata.
- RunModelControls (fabro-types) — resolved namespace value attached
  to RunModelSettings.
- resolve_run wires layer→namespace.

Values stay as plain strings here; concrete enum validation against
ReasoningEffort and Speed happens at request-time when the resolved
catalog is available. Per the plan, run-level controls are
overridable by node attributes and style-applied attributes; that
ordering is enforced at workflow control-resolution sites in a later
step.

Two new tests assert default-empty behavior and round-trip parsing.
This commit is contained in:
fabro-bot 2026-05-04 18:11:07 +00:00
parent 734756d86d
commit 8abe8ca515
10 changed files with 100 additions and 14 deletions

View file

@ -39,6 +39,7 @@ fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option<RunMod
provider: provider.map(InterpString::parse),
name: model.map(InterpString::parse),
fallbacks: Vec::new(),
controls: None,
})
}
@ -229,4 +230,4 @@ mod tests {
.is_none()
);
}
}
}

View file

@ -497,6 +497,7 @@ command = ["demo-mcp"]
provider: Some(InterpString::parse("openai")),
name: Some(InterpString::parse("gpt-5")),
fallbacks: Vec::new(),
controls: None,
}),
execution: Some(RunExecutionLayer {
mode: Some(RunMode::DryRun),
@ -541,4 +542,4 @@ command = ["demo-mcp"]
assert_eq!(settings.run.execution.approval, ApprovalMode::Auto);
assert!(!settings.run.execution.retros);
}
}
}

View file

@ -30,8 +30,9 @@ pub use run::{
GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer,
InterviewsLayer, LocalSandboxLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer,
NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer,
RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer, RunPrepareLayer,
RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice,
RunExecutionLayer, RunGitLayer, RunGoalLayer, RunLayer, RunModelControlsLayer, RunModelLayer,
RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer,
StringOrSplice,
};
pub use server::{
DiscordIntegrationLayer, GithubIntegrationLayer, IntegrationWebhooksLayer,

View file

@ -107,6 +107,37 @@ pub struct RunModelLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[option(default = "[]", value_type = "array<string>")]
pub fallbacks: Vec<ModelRefOrSplice>,
/// Run-level default values for typed model controls. Node attributes
/// and style-applied attributes still win over these defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controls: Option<RunModelControlsLayer>,
}
/// `[run.model.controls]` — run-level default control values.
///
/// Stored as plain strings here; concrete enum validation
/// (`ReasoningEffort`, `Speed`) happens at request-time when the resolved
/// catalog is available.
#[derive(
Debug,
Clone,
Default,
PartialEq,
Serialize,
Deserialize,
fabro_macros::Combine,
fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct RunModelControlsLayer {
/// Default reasoning-effort value for nodes that don't override it.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(value_type = "string")]
pub reasoning_effort: Option<String>,
/// Default speed value for nodes that don't override it.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[option(value_type = "string")]
pub speed: Option<String>,
}
/// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker.
@ -571,4 +602,4 @@ pub struct RunPullRequestLayer {
pub struct RunArtifactsLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
}
}

View file

@ -46,8 +46,9 @@ pub use layers::{
ModelRefOrSplice, ModelSettings, NotificationProviderLayer, NotificationRouteLayer,
ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, ProviderSettings,
ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunExecutionLayer,
RunGitLayer, RunGoalLayer, RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer,
RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
RunGitLayer, RunGoalLayer, RunLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer,
RunPullRequestLayer, RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer,
ServerArtifactsLayer,
ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,

View file

@ -72,6 +72,14 @@ fn resolve_model(model: Option<&RunModelLayer>) -> RunModelSettings {
ModelRefOrSplice::Splice => None,
})
.collect(),
controls: model
.controls
.as_ref()
.map(|c| fabro_types::settings::RunModelControls {
reasoning_effort: c.reasoning_effort.clone(),
speed: c.speed.clone(),
})
.unwrap_or_default(),
}
}
@ -497,4 +505,4 @@ fn resolve_artifacts(artifacts: Option<&RunArtifactsLayer>) -> ArtifactsSettings
fn size_to_gb_i32(bytes: u64) -> i32 {
let gb = bytes / 1_000_000_000;
i32::try_from(gb).unwrap_or(i32::MAX)
}
}

View file

@ -3,6 +3,37 @@ use fabro_types::settings::run::{ApprovalMode, RunGoal, RunMode, WorktreeMode};
use crate::{SettingsLayer, WorkflowSettingsBuilder};
#[test]
fn run_model_controls_round_trip_through_resolve() {
let settings = WorkflowSettingsBuilder::from_toml(
r#"
_version = 1
[run.model.controls]
reasoning_effort = "high"
speed = "fast"
"#,
)
.expect("[run.model.controls] should resolve")
.run;
assert_eq!(
settings.model.controls.reasoning_effort.as_deref(),
Some("high")
);
assert_eq!(settings.model.controls.speed.as_deref(), Some("fast"));
}
#[test]
fn run_model_controls_default_to_none() {
let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default())
.expect("empty settings should resolve")
.run;
assert!(settings.model.controls.reasoning_effort.is_none());
assert!(settings.model.controls.speed.is_none());
}
#[test]
fn resolves_run_defaults_from_empty_settings() {
let settings = WorkflowSettingsBuilder::from_layer(&SettingsLayer::default())
@ -79,4 +110,4 @@ name = "sonnet"
Some(InterpString::parse("anthropic"))
);
assert_eq!(settings.model.name, Some(InterpString::parse("sonnet")));
}
}

View file

@ -315,6 +315,7 @@ fn manifest_args_overrides(args: Option<&types::ManifestArgs>) -> ManifestSettin
provider: args.provider.as_deref().map(InterpString::parse),
name: args.model.as_deref().map(InterpString::parse),
fallbacks: Vec::new(),
controls: None,
});
let local_worktree = args
.worktree_mode
@ -1980,4 +1981,4 @@ digraph Demo {
);
assert!(response_mock.calls_async().await >= 1);
}
}
}

View file

@ -40,8 +40,8 @@ pub use run::{
GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings,
McpTransport, NotificationProviderSettings, NotificationRouteSettings, PullRequestSettings,
RunAgentSettings, RunCheckpointSettings, RunExecutionSettings, RunGitSettings, RunGoal,
RunInterviewsSettings, RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings,
RunScmSettings, ScmGitHubSettings, TlsMode,
RunInterviewsSettings, RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings,
RunSandboxSettings, RunScmSettings, ScmGitHubSettings, TlsMode,
};
pub use server::{
DiscordIntegrationSettings, GithubIntegrationSettings, IntegrationWebhooksSettings,
@ -52,4 +52,4 @@ pub use server::{
ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
};
pub use size::{ParseSizeError, Size};
pub use workflow::WorkflowNamespace;
pub use workflow::WorkflowNamespace;

View file

@ -50,6 +50,17 @@ pub struct RunModelSettings {
pub provider: Option<InterpString>,
pub name: Option<InterpString>,
pub fallbacks: Vec<ModelRef>,
/// Run-level default values for typed model controls
/// (`reasoning_effort`, `speed`). Node and style attributes still win
/// over these defaults.
#[serde(default)]
pub controls: RunModelControls,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RunModelControls {
pub reasoning_effort: Option<String>,
pub speed: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
@ -551,4 +562,4 @@ pub enum MergeStrategy {
Merge,
Squash,
Rebase,
}
}