Skip to content

Commit ccfef8e

Browse files
authored
Apply agent profile reasoning/temperature frontmatter (GH #828) (#829)
The reasoning and temperature frontmatter keys were parsed into AgentDef but never read. Wire them into the main loop: - /agent <name> applies the profile's reasoning via session.effort_override (the same mechanism /effort uses, so rebuilds keep it sticky), capturing the pre-profile override in ContextFiles.effort_before_agent — the effort sibling of route_before_agent. /agent off restores it. - The profile wins over a live /effort override at activation; a later /effort overrides it; /agent off restores the pre-profile value, mirroring how the model restore discards a mid-profile /model. - An unrecognised reasoning value warns and leaves effort untouched; the switch proceeds (fail-soft, matching /effort's unknown-level handling). - temperature is consulted from the active agent layer at agent build time (profile > CLI > config), so /agent's rebuild applies it and /agent off's rebuild falls back automatically. Same clamp/warn as before. - Subagent path unchanged (still model + prompt only); docs updated: the key table now lists all seven /effort levels and the new semantics. Verified end-to-end with the wire dump: turn under the profile flips to model=claude-haiku-4-5 reasoning=false, /agent off returns to claude-sonnet-5 reasoning=true.
1 parent 61a05fd commit ccfef8e

7 files changed

Lines changed: 249 additions & 6 deletions

File tree

docs/agents.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ All frontmatter keys are optional:
5151
| `model` | A `providers` alias **or** a model name. Resolved to a model string for the current client (see *Model routing* below). Omit to keep the current model. |
5252
| `deny_tools` | Tools to deny while this profile is active (e.g. `[bash, write, edit, apply_patch]`). |
5353
| `allow_tools` | The complement: deny every built-in **not** listed. `deny_tools` wins if both are given. |
54-
| `reasoning` | Reasoning-effort hint (`low` / `medium` / `high`). |
55-
| `temperature` | Sampling temperature. |
54+
| `reasoning` | Reasoning effort applied on activation (`off` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max` — the same levels as `/effort`). Wins over any live `/effort` override at activation; a later `/effort` overrides it; `/agent off` restores the pre-profile effort. An unrecognised value warns and leaves the session's effort unchanged. |
55+
| `temperature` | Sampling temperature while the profile is active (clamped to `0.0..=2.0`). Wins over `--temperature` and the config default; `/agent off` falls back to those. |
5656
| `description` | One-line summary shown in `/agents`. |
5757
| `subagent_tools` | Opt this profile's `task(agent=…)` subagent into tools: `readonly` (read-only tool universe), `readwrite` (readonly + write/edit/bash — can edit the repo), or `toolless` (the default one-shot). See *Tooled subagents* below. |
5858
| `subagent_max_turns` | Cap the tooled subagent's loop (default `25`). |
@@ -155,7 +155,8 @@ Profiles are resolved into subagent routes once at startup. By default
155155
subagents are **tool-less** (a one-shot query — a profile's
156156
`deny_tools`/`allow_tools` doesn't apply, since the subagent has no tools),
157157
and the profile's `reasoning`/`temperature` aren't applied on the subagent
158-
path — only the model and system prompt are. Routing `/plan` phases to named
158+
path — only the model and system prompt are (the MAIN loop applies both when
159+
the profile is activated with `/agent <name>`; see the key table above). Routing `/plan` phases to named
159160
profiles, and cross-provider client switching, remain follow-ups.
160161

161162
### Tooled subagents (opt-in)

src/agent/builder/agent_inner.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,9 +316,17 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
316316
let max_turns = cli.resolve_max_agent_turns(cfg);
317317
builder = builder.default_max_turns(max_turns);
318318

319-
// Temperature: CLI > config > unset. Previously only `cli.temperature`
320-
// was checked, so users couldn't set a default in config.json.
321-
if let Some(temp) = cli.resolve_temperature(cfg) {
319+
// Temperature: active agent profile > CLI > config > unset. Previously
320+
// only `cli.temperature` was checked, so users couldn't set a default in
321+
// config.json. The profile tier (GH #828) reads the `/agent` layer's
322+
// `temperature` frontmatter — parsed since the key was introduced but
323+
// never consumed. Consulting the layer HERE (rather than a runtime
324+
// setter, which `AnyAgent` has none of for temperature) means `/agent
325+
// <name>`'s rebuild picks it up and `/agent off`'s rebuild, with the
326+
// layer cleared, falls straight back to CLI/config — no capture/restore
327+
// needed. A profile omitting the key changes nothing.
328+
let profile_temp = context.agent_layer.as_ref().and_then(|d| d.temperature);
329+
if let Some(temp) = profile_temp.or_else(|| cli.resolve_temperature(cfg)) {
322330
let clamped = temp.clamp(0.0, 2.0);
323331
if (clamped - temp).abs() > f64::EPSILON {
324332
// Warn ONCE per process if the user's value was clamped

src/agent/builder/reminder_tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,7 @@ async fn build_agent_inner_emits_assembled_preamble() {
539539
prompt_layer: None,
540540
agent_layer: None,
541541
route_before_agent: None,
542+
effort_before_agent: None,
542543
};
543544
// Real openai client/model — never called (no network until
544545
// first request). The builder only inspects type bounds and
@@ -687,6 +688,7 @@ async fn preamble_lists_global_tier_skills() {
687688
prompt_layer: None,
688689
agent_layer: None,
689690
route_before_agent: None,
691+
effort_before_agent: None,
690692
};
691693
let client = openai::Client::new("test-key").expect("openai client builds");
692694
let model = client.completion_model("gpt-4o");
@@ -768,6 +770,7 @@ async fn no_skills_suppresses_the_preamble_catalog() {
768770
prompt_layer: None,
769771
agent_layer: None,
770772
route_before_agent: None,
773+
effort_before_agent: None,
771774
};
772775
let client = openai::Client::new("test-key").expect("openai client builds");
773776
let model = client.completion_model("gpt-4o");
@@ -816,6 +819,7 @@ async fn steering_fragment_tracks_active_model_not_cli() {
816819
prompt_layer: None,
817820
agent_layer: None,
818821
route_before_agent: None,
822+
effort_before_agent: None,
819823
};
820824
let client = openai::Client::new("test-key").expect("openai client builds");
821825

src/context/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ pub struct ContextFiles {
6060
/// leave the pre-agent model pointed at the profile's provider
6161
/// (dirge-fhr5). `None` when no agent is active.
6262
pub route_before_agent: Option<crate::provider::ModelRoute>,
63+
/// The `session.effort_override` in force when an agent profile first
64+
/// applied its `reasoning` frontmatter (GH #828), so `/agent off` can
65+
/// restore it — the effort sibling of `route_before_agent`. Outer
66+
/// `None` = nothing captured (no active profile has applied
67+
/// `reasoning`); `Some(None)` = there was no override before the
68+
/// profile (restore falls back to the provider config default on
69+
/// rebuild).
70+
pub effort_before_agent: Option<Option<crate::agent::agent_loop::types::ThinkingLevel>>,
6371
}
6472

6573
/// The `/prompt`-selected layer: a named mode with a body and its own
@@ -191,6 +199,7 @@ pub fn load(no_context_files: bool) -> ContextFiles {
191199
prompt_layer: None,
192200
agent_layer: None,
193201
route_before_agent: None,
202+
effort_before_agent: None,
194203
}
195204
}
196205

@@ -306,6 +315,7 @@ mod composition_tests {
306315
prompt_layer: None,
307316
agent_layer: None,
308317
route_before_agent: None,
318+
effort_before_agent: None,
309319
}
310320
}
311321

src/provider/mod_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,6 +1702,7 @@ async fn cerebras_identity_survives_client_model_and_agent_construction() {
17021702
prompt_layer: None,
17031703
agent_layer: None,
17041704
route_before_agent: None,
1705+
effort_before_agent: None,
17051706
};
17061707
let agent = build_agent(
17071708
model,
@@ -1768,6 +1769,7 @@ async fn provider_effort_config_seeds_agent_reasoning() {
17681769
prompt_layer: None,
17691770
agent_layer: None,
17701771
route_before_agent: None,
1772+
effort_before_agent: None,
17711773
};
17721774
let agent = build_agent(
17731775
model,

src/ui/slash/cmd/agent/clear.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ pub(crate) async fn cmd_agent_clear(ctx: &mut SlashCtx<'_>) -> anyhow::Result<()
3333
));
3434
}
3535

36+
// Restore the pre-profile effort override captured when a profile's
37+
// `reasoning` frontmatter was applied (GH #828) — the effort sibling of
38+
// the route restore above, and deliberately independent of it: a refused
39+
// model restore must not leave the profile's reasoning behind. Restoring
40+
// the pre-profile value also discards any `/effort` issued WHILE the
41+
// profile was active, exactly as the route restore discards a mid-profile
42+
// `/model`. Done before `rebuild_agent` so the rebuild installs the
43+
// restored override (or, for `Some(None)`, re-seeds the provider config
44+
// default) on the live agent.
45+
restore_profile_reasoning(
46+
&mut ctx.session.effort_override,
47+
&mut ctx.context.effort_before_agent,
48+
);
49+
3650
rebuild_agent(ctx).await;
3751

3852
if let Some(err) = restore_error {
@@ -47,3 +61,17 @@ pub(crate) async fn cmd_agent_clear(ctx: &mut SlashCtx<'_>) -> anyhow::Result<()
4761
ctx.renderer.write_line(&msg, c_agent())?;
4862
Ok(())
4963
}
64+
65+
/// Put `session.effort_override` back to the value captured when an agent
66+
/// profile first applied its `reasoning` frontmatter (GH #828). A no-op
67+
/// when nothing was captured (no active profile ever set `reasoning`), so a
68+
/// profile without the key leaves the session's effort untouched in both
69+
/// directions.
70+
pub(crate) fn restore_profile_reasoning(
71+
effort_override: &mut Option<crate::agent::agent_loop::types::ThinkingLevel>,
72+
effort_before_agent: &mut Option<Option<crate::agent::agent_loop::types::ThinkingLevel>>,
73+
) {
74+
if let Some(prior) = effort_before_agent.take() {
75+
*effort_override = prior;
76+
}
77+
}

src/ui/slash/cmd/agent/switch.rs

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ pub(crate) async fn cmd_agent_switch(ctx: &mut SlashCtx<'_>, arg: &str) -> anyho
5858
}
5959
}
6060

61+
// Apply the profile's `reasoning` frontmatter (GH #828) — parsed since
62+
// the key was introduced but never consumed. It layers exactly like the
63+
// model: the profile's level wins at activation (over any live `/effort`
64+
// override), a later `/effort` overrides it as it would any current
65+
// level, and `/agent off` restores what was captured here. Applied even
66+
// when the model route was refused above — the profile stays active in
67+
// that case, so its reasoning does too. Written to
68+
// `session.effort_override` (not `set_reasoning` directly) so the
69+
// `rebuild_agent` below installs it on the live agent and every later
70+
// rebuild keeps it sticky, the same way `/effort` survives `/model`.
71+
let mut applied_effort = None;
72+
if let Some(raw) = def.reasoning.as_deref() {
73+
match apply_profile_reasoning(
74+
raw,
75+
&mut ctx.session.effort_override,
76+
&mut ctx.context.effort_before_agent,
77+
) {
78+
Ok(level) => applied_effort = Some(level),
79+
// Fail soft: warn and leave the session's effort alone, the same
80+
// way `/effort` treats an unknown level. Never abort the switch —
81+
// the rest of the profile is still valid.
82+
Err(msg) => ctx
83+
.renderer
84+
.write_line(&format!("agent '{}': {msg}", def.name), c_error())?,
85+
}
86+
}
87+
6188
rebuild_agent(ctx).await;
6289

6390
let mut summary = format!("active agent: {}", def.name);
@@ -67,6 +94,169 @@ pub(crate) async fn cmd_agent_switch(ctx: &mut SlashCtx<'_>, arg: &str) -> anyho
6794
if let Some(alias) = &switched_to {
6895
summary.push_str(&format!(" · {alias}"));
6996
}
97+
if let Some(level) = applied_effort {
98+
summary.push_str(&format!(" · effort {}", level.effort_label()));
99+
}
70100
ctx.renderer.write_line(&summary, c_agent())?;
71101
Ok(())
72102
}
103+
104+
/// Apply an agent profile's `reasoning` frontmatter value to the session's
105+
/// effort override (GH #828), capturing the pre-profile override on the
106+
/// FIRST profile application so `/agent off` can restore it. The capture
107+
/// guard is `effort_before_agent`, not the agent layer: profile A without a
108+
/// `reasoning` key followed by profile B with one must capture at B, and
109+
/// A-with-B-with must keep A's capture (the pre-agent value) — mirroring
110+
/// how `route_before_agent` holds the pre-agent route across profile hops.
111+
///
112+
/// Returns the level applied, or `Err` with a warning message (worded like
113+
/// `/effort`'s unknown-level error) when `raw` is not a recognised level —
114+
/// in which case NOTHING is touched: no capture, no override change.
115+
pub(crate) fn apply_profile_reasoning(
116+
raw: &str,
117+
effort_override: &mut Option<crate::agent::agent_loop::types::ThinkingLevel>,
118+
effort_before_agent: &mut Option<Option<crate::agent::agent_loop::types::ThinkingLevel>>,
119+
) -> Result<crate::agent::agent_loop::types::ThinkingLevel, String> {
120+
use crate::agent::agent_loop::types::ThinkingLevel;
121+
let Some(level) = ThinkingLevel::from_effort_str(raw) else {
122+
return Err(format!(
123+
"unknown reasoning `{}` — expected off/minimal/low/medium/high/xhigh/max; \
124+
leaving effort unchanged",
125+
raw.trim(),
126+
));
127+
};
128+
if effort_before_agent.is_none() {
129+
*effort_before_agent = Some(*effort_override);
130+
}
131+
*effort_override = Some(level);
132+
Ok(level)
133+
}
134+
135+
#[cfg(test)]
136+
mod tests {
137+
use super::super::clear::restore_profile_reasoning;
138+
use super::apply_profile_reasoning;
139+
use crate::agent::agent_loop::types::ThinkingLevel;
140+
141+
// GH #828: a profile's `reasoning` is applied on activation and the
142+
// pre-profile state (no override) is captured for `/agent off`.
143+
#[test]
144+
fn profile_reasoning_is_applied_and_prior_state_captured() {
145+
let mut over = None;
146+
let mut before = None;
147+
let applied = apply_profile_reasoning("low", &mut over, &mut before);
148+
assert_eq!(applied, Ok(ThinkingLevel::Low));
149+
assert_eq!(over, Some(ThinkingLevel::Low));
150+
assert_eq!(before, Some(None), "must capture 'no prior override'");
151+
}
152+
153+
// Precedence at activation: the profile wins over a live `/effort`
154+
// override, the same way its model wins over a `/model` choice — and
155+
// the displaced override is what `/agent off` will restore.
156+
#[test]
157+
fn profile_reasoning_wins_over_live_effort_override_at_activation() {
158+
let mut over = Some(ThinkingLevel::Max);
159+
let mut before = None;
160+
let applied = apply_profile_reasoning("low", &mut over, &mut before);
161+
assert_eq!(applied, Ok(ThinkingLevel::Low));
162+
assert_eq!(over, Some(ThinkingLevel::Low));
163+
assert_eq!(before, Some(Some(ThinkingLevel::Max)));
164+
}
165+
166+
// `/agent off` restores the pre-profile override.
167+
#[test]
168+
fn restore_returns_the_pre_profile_override() {
169+
let mut over = Some(ThinkingLevel::High);
170+
let mut before = None;
171+
apply_profile_reasoning("off", &mut over, &mut before).unwrap();
172+
restore_profile_reasoning(&mut over, &mut before);
173+
assert_eq!(over, Some(ThinkingLevel::High));
174+
assert_eq!(before, None, "capture must be consumed");
175+
}
176+
177+
// `/agent off` after a profile applied over NO prior override restores
178+
// "no override" (the rebuild then re-seeds the provider config default).
179+
#[test]
180+
fn restore_returns_no_override_when_there_was_none_before() {
181+
let mut over = None;
182+
let mut before = None;
183+
apply_profile_reasoning("medium", &mut over, &mut before).unwrap();
184+
restore_profile_reasoning(&mut over, &mut before);
185+
assert_eq!(over, None);
186+
assert_eq!(before, None);
187+
}
188+
189+
// A `/effort` issued WHILE the profile is active is discarded by
190+
// `/agent off` in favour of the pre-profile value — mirroring how the
191+
// route restore discards a mid-profile `/model`.
192+
#[test]
193+
fn restore_discards_a_mid_profile_effort_change() {
194+
let mut over = Some(ThinkingLevel::Medium);
195+
let mut before = None;
196+
apply_profile_reasoning("low", &mut over, &mut before).unwrap();
197+
over = Some(ThinkingLevel::Xhigh); // user ran `/effort xhigh` mid-profile
198+
restore_profile_reasoning(&mut over, &mut before);
199+
assert_eq!(over, Some(ThinkingLevel::Medium));
200+
}
201+
202+
// A profile that omits `reasoning` never calls apply — so on `/agent
203+
// off` there is no capture, and restore must change NOTHING (a
204+
// key-less profile leaves effort alone in both directions).
205+
#[test]
206+
fn restore_without_a_capture_is_a_no_op() {
207+
let mut over = Some(ThinkingLevel::Max);
208+
let mut before = None;
209+
restore_profile_reasoning(&mut over, &mut before);
210+
assert_eq!(over, Some(ThinkingLevel::Max));
211+
}
212+
213+
// An invalid value fails soft: warn (the Err), touch nothing, never
214+
// abort the switch.
215+
#[test]
216+
fn invalid_reasoning_value_changes_nothing() {
217+
let mut over = Some(ThinkingLevel::High);
218+
let mut before = None;
219+
let res = apply_profile_reasoning("turbo", &mut over, &mut before);
220+
assert!(res.is_err());
221+
assert!(res.unwrap_err().contains("unknown reasoning `turbo`"));
222+
assert_eq!(over, Some(ThinkingLevel::High), "override untouched");
223+
assert_eq!(before, None, "no capture on failure");
224+
}
225+
226+
// Hopping profile A -> profile B keeps A's capture: the value `/agent
227+
// off` restores is the PRE-AGENT one, exactly as `route_before_agent`
228+
// holds the pre-agent route across profile hops.
229+
#[test]
230+
fn profile_hop_keeps_the_pre_agent_capture() {
231+
let mut over = Some(ThinkingLevel::Minimal);
232+
let mut before = None;
233+
apply_profile_reasoning("high", &mut over, &mut before).unwrap();
234+
apply_profile_reasoning("max", &mut over, &mut before).unwrap();
235+
assert_eq!(over, Some(ThinkingLevel::Max));
236+
assert_eq!(before, Some(Some(ThinkingLevel::Minimal)));
237+
restore_profile_reasoning(&mut over, &mut before);
238+
assert_eq!(over, Some(ThinkingLevel::Minimal));
239+
}
240+
241+
// All seven `/effort` levels are accepted — the profile key must never
242+
// diverge from `/effort`'s vocabulary (they share the parser).
243+
#[test]
244+
fn all_seven_effort_levels_parse() {
245+
for (raw, want) in [
246+
("off", ThinkingLevel::Off),
247+
("minimal", ThinkingLevel::Minimal),
248+
("low", ThinkingLevel::Low),
249+
("medium", ThinkingLevel::Medium),
250+
("high", ThinkingLevel::High),
251+
("xhigh", ThinkingLevel::Xhigh),
252+
("max", ThinkingLevel::Max),
253+
] {
254+
let mut over = None;
255+
let mut before = None;
256+
assert_eq!(
257+
apply_profile_reasoning(raw, &mut over, &mut before),
258+
Ok(want)
259+
);
260+
}
261+
}
262+
}

0 commit comments

Comments
 (0)