feat(engine): inject mode-change runtime message and include mode in turn metadata

Inject a mode-change runtime message into the engine and include mode information in turn metadata for better tracking.
This commit is contained in:
Justin Gao
2026-06-03 11:47:36 +08:00
committed by GitHub
parent 1781312c7a
commit 29acb87a9d
3 changed files with 227 additions and 3 deletions
+67 -1
View File
@@ -496,6 +496,8 @@ pub struct Engine {
/// This keeps prompt refreshes cheap while still noticing append/update
/// writes from slop ledger tools during the same session.
slop_ledger_gate_cache: Option<(Option<SystemTime>, Option<String>)>,
/// Current operating mode. Updated on `ChangeMode` and `SendMessage`.
current_mode: AppMode,
}
// === Internal tool helpers ===
@@ -739,6 +741,7 @@ impl Engine {
slop_ledger_gate_cache: None,
workshop_vars,
sandbox_backend,
current_mode: AppMode::Agent,
};
engine.rehydrate_latest_canonical_state();
@@ -1164,11 +1167,23 @@ impl Engine {
let _ = self.tx_event.send(Event::AgentList { agents }).await;
}
Op::ChangeMode { mode } => {
let previous_mode = self.current_mode;
self.current_mode = mode;
self.refresh_system_prompt(mode);
self.emit_session_updated().await;
// Notify the agent that the mode has changed so it can re-evaluate
// any operations that were blocked by the previous mode's policy.
if previous_mode != mode {
let msg = Self::mode_change_runtime_message(previous_mode, mode);
self.session.add_message(msg);
self.emit_session_updated().await;
}
let _ = self
.tx_event
.send(Event::status(format!("Mode changed to: {mode:?}")))
.send(Event::status(format!(
"Mode changed to: {}",
mode.description()
)))
.await;
}
Op::SetModel { model, mode } => {
@@ -1308,6 +1323,47 @@ impl Engine {
.await;
}
/// Build a runtime event message notifying the agent that the operating mode has changed.
/// This lets the agent re-evaluate any operations that were blocked by the previous mode.
fn mode_change_runtime_message(previous_mode: AppMode, new_mode: AppMode) -> Message {
let (policy_note, re_eval_hint) = match new_mode {
AppMode::Yolo => (
"all operations run automatically without requiring user approval",
"Re-evaluate any previously blocked write, patch, or shell operations \
— they are now auto-approved.",
),
AppMode::Agent => (
"read-only operations run silently; writes, patches, and shell \
commands require user approval",
"Any operations you ran automatically under YOLO mode now require \
explicit user approval before executing.",
),
AppMode::Plan => (
"all writes and patches are blocked; shell and code execution are unavailable",
"Any previously planned operations that require writes or shell access \
must wait until the mode changes back to Agent or YOLO.",
),
};
Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: format!(
"<codewhale:runtime_event kind=\"mode_change\" visibility=\"internal\">\n\
This is an internal runtime event, not user input. The operating mode has changed \
from {previous} mode to {new} mode.\n\n\
In {new} mode: {policy}\n\n\
{re_eval}\n\
</codewhale:runtime_event>",
previous = previous_mode.description(),
new = new_mode.description(),
policy = policy_note,
re_eval = re_eval_hint,
),
cache_control: None,
}],
}
}
async fn add_session_message(&mut self, message: Message) {
self.session.add_message(message);
self.emit_session_updated().await;
@@ -1316,11 +1372,13 @@ impl Engine {
fn turn_metadata_block(
&self,
routed_model: &str,
mode: AppMode,
auto_model: bool,
reasoning_effort: Option<&str>,
reasoning_effort_auto: bool,
) -> ContentBlock {
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let mode_label = mode.description();
let working_set_summary = self
.session
.working_set
@@ -1330,6 +1388,7 @@ impl Engine {
let mut lines = vec![
format!("Current local date: {today}"),
format!("Current mode: {mode_label}"),
format!("Current model: {routed_model}"),
];
if auto_model {
@@ -1352,6 +1411,7 @@ impl Engine {
fn user_text_message_with_turn_metadata(&self, text: String) -> Message {
self.user_text_message_with_turn_metadata_for_route(
text,
self.current_mode,
&self.session.model,
self.session.auto_model,
self.session.reasoning_effort.as_deref(),
@@ -1362,6 +1422,7 @@ impl Engine {
fn user_text_message_with_turn_metadata_for_route(
&self,
text: String,
mode: AppMode,
routed_model: &str,
auto_model: bool,
reasoning_effort: Option<&str>,
@@ -1372,6 +1433,7 @@ impl Engine {
content: vec![
self.turn_metadata_block(
routed_model,
mode,
auto_model,
reasoning_effort,
reasoning_effort_auto,
@@ -1407,6 +1469,9 @@ impl Engine {
// Reset cancel token for fresh turn (in case previous was cancelled)
self.reset_cancel_token();
// Track current mode so mid-turn messages include the right mode in turn metadata.
self.current_mode = mode;
// Drain stale steer messages from previous turns.
while self.rx_steer.try_recv().is_ok() {}
@@ -1486,6 +1551,7 @@ impl Engine {
// Add user message to session
let user_msg = self.user_text_message_with_turn_metadata_for_route(
content,
mode,
&model,
auto_model,
reasoning_effort.as_deref(),
+144
View File
@@ -1693,6 +1693,61 @@ async fn change_mode_refreshes_session_prompt_and_updates_session() {
assert!(prompt.contains("Approval Policy: Auto"));
}
#[tokio::test]
async fn change_mode_op_injects_runtime_event_into_session_messages() {
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
model: "deepseek-v4-pro".to_string(),
..Default::default()
};
let (engine, handle) = Engine::new(config, &Config::default());
let run = tokio::spawn(engine.run());
// Switch from default Agent → YOLO
handle
.send(Op::ChangeMode {
mode: AppMode::Yolo,
})
.await
.expect("send change mode");
// Collect session-updated events until we see the injected message
let messages = {
let mut rx = handle.rx_event.write().await;
loop {
let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("session update after mode switch")
.expect("event");
if let Event::SessionUpdated { messages, .. } = event {
// The last message should be our runtime event
if let Some(last) = messages.last()
&& let ContentBlock::Text { text, .. } =
last.content.first().expect("text block")
&& text.contains("kind=\"mode_change\"")
{
break messages;
}
}
}
};
run.abort();
let last = messages.last().expect("at least one message");
let ContentBlock::Text { text, .. } = last.content.first().expect("text block") else {
panic!("expected text block");
};
assert!(
text.contains("Agent mode") && text.contains("YOLO mode"),
"should contain both previous and new mode: {text}"
);
assert!(
text.contains("Re-evaluate"),
"should tell agent to re-evaluate: {text}"
);
}
#[test]
fn detects_context_length_errors_from_provider_payloads() {
let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
@@ -2118,6 +2173,7 @@ fn turn_metadata_includes_auto_model_route() {
let user_msg = engine.user_text_message_with_turn_metadata_for_route(
"debug this regression".to_string(),
AppMode::Agent,
"deepseek-v4-pro",
true,
Some("max"),
@@ -2134,6 +2190,94 @@ fn turn_metadata_includes_auto_model_route() {
assert!(!text.contains("debug this regression"));
}
#[test]
fn turn_metadata_includes_current_mode() {
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
..Default::default()
};
let (engine, _handle) = Engine::new(config, &Config::default());
let user_msg = engine.user_text_message_with_turn_metadata_for_route(
"test mode metadata".to_string(),
AppMode::Yolo,
"deepseek-v4-flash",
false,
None,
false,
);
let first_block = user_msg.content.first().expect("turn metadata block");
let ContentBlock::Text { text, .. } = first_block else {
panic!("expected text metadata block");
};
assert!(
text.contains("Current mode: YOLO mode - full tool access without approvals"),
"turn metadata should include the current mode label, got: {text}"
);
}
#[test]
fn turn_metadata_mode_updates_with_change_mode_op() {
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
..Default::default()
};
let (mut engine, _handle) = Engine::new(config, &Config::default());
// In agent mode by default
let msg = engine.user_text_message_with_turn_metadata("hello".to_string());
let first_block = msg.content.first().expect("turn metadata block");
let ContentBlock::Text { text, .. } = first_block else {
panic!("expected text metadata block");
};
assert!(
text.contains("Agent mode"),
"initial mode should be Agent, got: {text}"
);
// Switch to YOLO — user_text_message_with_turn_metadata should reflect the new mode
engine.current_mode = AppMode::Yolo;
let msg = engine.user_text_message_with_turn_metadata("hello again".to_string());
let first_block = msg.content.first().expect("turn metadata block");
let ContentBlock::Text { text, .. } = first_block else {
panic!("expected text metadata block");
};
assert!(
text.contains("YOLO mode"),
"mode after change should be YOLO, got: {text}"
);
}
#[test]
fn mode_change_runtime_message_format() {
let msg = Engine::mode_change_runtime_message(AppMode::Agent, AppMode::Yolo);
assert_eq!(msg.role, "user");
let ContentBlock::Text { text, .. } = msg.content.first().expect("text block") else {
panic!("expected text block");
};
assert!(
text.contains("codewhale:runtime_event"),
"should be a runtime event message"
);
assert!(
text.contains("kind=\"mode_change\""),
"should have mode_change kind"
);
assert!(
text.contains("Agent mode") && text.contains("YOLO mode"),
"should mention both previous and new mode: {text}"
);
assert!(
text.contains("Re-evaluate"),
"should tell agent to re-evaluate blocked operations: {text}"
);
}
#[test]
fn user_text_message_keeps_current_turn_input_after_turn_metadata() {
let tmp = tempdir().expect("tempdir");
+16 -2
View File
@@ -322,7 +322,9 @@ impl Engine {
// Three-zone prefix contract (#2264): freeze baseline on first
// turn, verify against it on subsequent turns. Operates alongside
// PrefixStabilityManager as an independent diagnostic layer.
// Phase 2: warn-only, auto-re-freeze on drift.
// Phase 3: emit a one-shot 'frozen' event on first turn.
// Drift is logged (tracing::debug!) but not re-emitted —
// PrefixStabilityManager already reports the change above.
let system_text =
crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref());
let current_tools: &[crate::models::Tool] = active_tools.as_deref().unwrap_or_default();
@@ -346,7 +348,19 @@ impl Engine {
self.session.system_prompt.as_ref(),
current_tools.to_vec(),
);
self.session.frozen_prefix = Some(pinned.freeze());
let frozen = pinned.freeze();
let _ = self
.tx_event
.send(Event::PrefixCacheChange {
description: format!("frozen: {}", frozen.short_id()),
system_prompt_changed: false,
tools_changed: false,
stability_pct: 100,
changed: false,
pinned_combined_hash: frozen.hash().to_string(),
})
.await;
self.session.frozen_prefix = Some(frozen);
}
}