From 8abe8ca51555399fd6bd1536648835da039aae1c Mon Sep 17 00:00:00 2001 From: fabro-bot Date: Mon, 4 May 2026 18:11:07 +0000 Subject: [PATCH] feat(config,types): add [run.model.controls] for run-level control defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fabro-cli/src/commands/run/overrides.rs | 3 +- lib/crates/fabro-config/src/builders.rs | 3 +- lib/crates/fabro-config/src/layers/mod.rs | 5 +-- lib/crates/fabro-config/src/layers/run.rs | 33 ++++++++++++++++++- lib/crates/fabro-config/src/lib.rs | 5 +-- lib/crates/fabro-config/src/resolve/run.rs | 10 +++++- .../fabro-config/src/tests/resolve_run.rs | 33 ++++++++++++++++++- lib/crates/fabro-server/src/run_manifest.rs | 3 +- lib/crates/fabro-types/src/settings/mod.rs | 6 ++-- lib/crates/fabro-types/src/settings/run.rs | 13 +++++++- 10 files changed, 100 insertions(+), 14 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index c1b73177b..20de26666 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -39,6 +39,7 @@ fn model_from_args(model: Option<&str>, provider: Option<&str>) -> Option, + /// 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, +} + +/// `[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, + /// 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, } /// 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, -} +} \ No newline at end of file diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index d1942d517..3df4af086 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -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, diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index 8bb9b0703..c557845cd 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -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) -} +} \ No newline at end of file diff --git a/lib/crates/fabro-config/src/tests/resolve_run.rs b/lib/crates/fabro-config/src/tests/resolve_run.rs index 15ab2dce9..e2cfbaf17 100644 --- a/lib/crates/fabro-config/src/tests/resolve_run.rs +++ b/lib/crates/fabro-config/src/tests/resolve_run.rs @@ -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"))); -} +} \ No newline at end of file diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 713dcc50c..c52d96657 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -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); } -} +} \ No newline at end of file diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 003e70c82..a17643882 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -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; \ No newline at end of file diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index 7619a92fb..9e8409e94 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -50,6 +50,17 @@ pub struct RunModelSettings { pub provider: Option, pub name: Option, pub fallbacks: Vec, + /// 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, + pub speed: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] @@ -551,4 +562,4 @@ pub enum MergeStrategy { Merge, Squash, Rebase, -} +} \ No newline at end of file