Skip to content

Commit 1b479c3

Browse files
feat(runtime): runtime-switch permission/decision/sandbox modes via TUI + ACP (#194)
Adds session-only runtime switching of PermissionMode, DecisionMode and SandboxPolicy — previously fixed at startup — so users can adjust them mid-session without restarting. - protocol: new ControlCommand::{Permission,Decision,Sandbox}*Switch + AgentEventPayload::*Changed; ObservableAgentState carries the 3 values - runtime: handle_control branches (emit-first), shared DecisionCell atomic so /decision flips without rebuilding the handler chain, Kernel sandbox RwLock + backend rebuild; plan-exit re-emits/keeps a mid-plan /permission - TUI: /permission /decision /sandbox enum pickers + /status Config overlay showing live (session) values; ACP set_config_option maps the 3 configIds - DecisionMode/SandboxPolicy follow startup config for sub-agent spawn inheritance (parent-local switches); PermissionMode gains Default (Bypass) Covered by unit + e2e tests; all source files kept <=200 lines.
1 parent 657053a commit 1b479c3

60 files changed

Lines changed: 1146 additions & 88 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/loopal-acp/src/adapter/control.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,19 @@ impl AcpAdapter {
3939
}
4040
}
4141

42-
/// Handle `session/set_config_option` → HubClient.send_control(ModelSwitch|ThinkingSwitch).
42+
/// Handle `session/set_config_option` → forwards the value as the matching
43+
/// runtime `ControlCommand` (model / thinking / permission / decision /
44+
/// sandbox). Unknown `configId`s are rejected with INVALID_REQUEST.
4345
pub(crate) async fn handle_set_config_option(&self, id: i64, params: Value) {
4446
let config_id = params["configId"].as_str().unwrap_or("");
4547
let value = params["value"].as_str().unwrap_or("").to_string();
4648

4749
let cmd = match config_id {
4850
"model" => ControlCommand::ModelSwitch(value),
4951
"thinking" => ControlCommand::ThinkingSwitch(value),
52+
"permission" => ControlCommand::PermissionModeSwitch(value),
53+
"decision" => ControlCommand::DecisionModeSwitch(value),
54+
"sandbox" => ControlCommand::SandboxPolicySwitch(value),
5055
_ => {
5156
self.acp_out
5257
.respond_error(

crates/loopal-acp/src/translate/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ pub fn translate_event(payload: &AgentEventPayload, session_id: &str) -> Option<
162162
| AgentEventPayload::UserMessageQueued { .. }
163163
| AgentEventPayload::ModelChanged { .. }
164164
| AgentEventPayload::ThinkingChanged { .. }
165+
| AgentEventPayload::PermissionModeChanged { .. }
166+
| AgentEventPayload::DecisionModeChanged { .. }
167+
| AgentEventPayload::SandboxPolicyChanged { .. }
165168
| AgentEventPayload::ThreadGoalUpdated { .. }
166169
| AgentEventPayload::ClassifierProgress { .. }
167170
| AgentEventPayload::ClassifierFailed { .. }

crates/loopal-acp/tests/suite/e2e_alignment_test.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,31 @@ async fn test_set_config_option_thinking() {
7878
assertions::assert_json_rpc_ok(&resp);
7979
}
8080

81+
#[tokio::test]
82+
async fn test_set_config_option_runtime_switches() {
83+
let mut harness = build_acp_harness(vec![]).await;
84+
let _sid = setup_session(&mut harness).await;
85+
86+
for (config_id, value) in [
87+
("permission", "ask_any_write"),
88+
("decision", "classifier"),
89+
("sandbox", "read_only"),
90+
] {
91+
let resp = harness
92+
.request(
93+
"session/set_config_option",
94+
json!({"configId": config_id, "value": value}),
95+
)
96+
.await;
97+
assertions::assert_json_rpc_ok(&resp);
98+
assert_eq!(
99+
resp["result"]["configOptions"],
100+
json!([]),
101+
"{config_id} switch should return the standard configOptions body"
102+
);
103+
}
104+
}
105+
81106
#[tokio::test]
82107
async fn test_notification_uses_session_update_tag() {
83108
// Verify the wire format uses "sessionUpdate" (ACP standard) not "kind".

crates/loopal-agent-server/src/agent_loop_params_factory.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub(crate) struct AgentLoopAssembly {
3131
pub fetch_refiner_policy: Option<Arc<dyn FetchRefinerPolicy>>,
3232
pub goal_session: Option<Arc<GoalRuntimeSession>>,
3333
pub scheduler: Arc<loopal_scheduler::CronScheduler>,
34+
pub decision_cell: loopal_runtime::frontend::DecisionCell,
3435
}
3536

3637
pub(crate) fn assemble_agent_loop_params(a: AgentLoopAssembly) -> AgentLoopParams {
@@ -51,7 +52,8 @@ pub(crate) fn assemble_agent_loop_params(a: AgentLoopAssembly) -> AgentLoopParam
5152
.message_snapshot(a.message_snapshot)
5253
.resume_hooks(a.resume_hooks)
5354
.memory_channel_opt(a.memory_channel)
54-
.scheduler(a.scheduler);
55+
.scheduler(a.scheduler)
56+
.decision_cell(a.decision_cell);
5557
let builder = match a.one_shot_chat {
5658
Some(s) => builder.one_shot_chat(s),
5759
None => builder,

crates/loopal-agent-server/src/agent_setup.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub async fn build_with_frontend(ctx: AgentSetupContext<'_>) -> anyhow::Result<A
2222
session_dir_override,
2323
hub,
2424
decision_context,
25+
decision_cell,
2526
session_id,
2627
} = ctx;
2728
let router = build_model_router(&config.settings);
@@ -187,6 +188,7 @@ pub async fn build_with_frontend(ctx: AgentSetupContext<'_>) -> anyhow::Result<A
187188
fetch_refiner_policy: Some(fetch_refiner_policy),
188189
goal_session,
189190
scheduler: scheduler.clone(),
191+
decision_cell,
190192
},
191193
);
192194
Ok(AgentSetupResult {

crates/loopal-agent-server/src/agent_setup_context.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub struct AgentSetupContext<'a> {
3737
pub session_dir_override: Option<&'a std::path::Path>,
3838
pub hub: &'a crate::session_hub::SessionHub,
3939
pub decision_context: loopal_runtime::frontend::DecisionContext,
40+
pub decision_cell: loopal_runtime::frontend::DecisionCell,
4041
pub session_id: &'a str,
4142
}
4243

@@ -54,6 +55,7 @@ impl<'a> AgentSetupContext<'a> {
5455
session_dir_override: Option<&'a std::path::Path>,
5556
hub: &'a crate::session_hub::SessionHub,
5657
decision_context: loopal_runtime::frontend::DecisionContext,
58+
decision_cell: loopal_runtime::frontend::DecisionCell,
5759
session_id: &'a str,
5860
) -> Self {
5961
Self {
@@ -68,6 +70,7 @@ impl<'a> AgentSetupContext<'a> {
6870
session_dir_override,
6971
hub,
7072
decision_context,
73+
decision_cell,
7174
session_id,
7275
}
7376
}

crates/loopal-agent-server/src/session_handlers_factory.rs

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use loopal_runtime::frontend::permission_handler::PermissionHandler;
88
use loopal_runtime::frontend::question_handler::QuestionHandler;
99
use loopal_runtime::frontend::traits::EventEmitter;
1010
use loopal_runtime::frontend::{
11-
ClassifierPermissionHandler, ClassifierQuestionHandler, DecisionContext,
11+
ClassifierPermissionHandler, ClassifierQuestionHandler, DecisionCell, DecisionContext,
1212
};
1313

1414
use crate::hub_broadcaster::HubBroadcaster;
@@ -35,21 +35,21 @@ pub fn build_session_handlers(
3535
kernel: &Arc<Kernel>,
3636
session: SessionRef,
3737
context: DecisionContext,
38-
) -> (Box<dyn PermissionHandler>, Box<dyn QuestionHandler>) {
38+
) -> (
39+
Box<dyn PermissionHandler>,
40+
Box<dyn QuestionHandler>,
41+
DecisionCell,
42+
) {
3943
let ipc_perm: Box<dyn PermissionHandler> = Box::new(IpcPermissionHandler::new(session.clone()));
4044
let ipc_q_arc: Arc<dyn QuestionHandler> = Arc::new(IpcQuestionHandler::new(session.clone()));
41-
match config.settings.decision_mode {
42-
DecisionMode::Manual => {
43-
let ipc_q: Box<dyn QuestionHandler> = Box::new(IpcQuestionHandler::new(session));
44-
return (ipc_perm, ipc_q);
45-
}
46-
DecisionMode::Classifier => {}
47-
DecisionMode::Agent => {
48-
tracing::warn!(
49-
"DecisionMode::Agent is not yet implemented; falling back to Classifier"
50-
);
51-
}
45+
if config.settings.decision_mode == DecisionMode::Agent {
46+
tracing::warn!("DecisionMode::Agent is not yet implemented; falling back to Classifier");
5247
}
48+
// Always build the classifier-wrapped chain; the DecisionCell decides at
49+
// request time whether to run the classifier or delegate to the IPC
50+
// fallback (Manual). This lets /decision flip modes mid-session without
51+
// rebuilding the handler chain.
52+
let decision = DecisionCell::new(config.settings.decision_mode);
5353
let classifier = Arc::new(
5454
loopal_classifier::ClassifierEngine::new_with_thresholds(
5555
config.instructions.clone(),
@@ -70,14 +70,18 @@ pub fn build_session_handlers(
7070
router,
7171
});
7272
let emitter: Arc<dyn EventEmitter> = Arc::new(HubBroadcaster::new(session, None));
73-
let auto_perm: Box<dyn PermissionHandler> = Box::new(ClassifierPermissionHandler::new(
74-
classifier.clone(),
75-
ipc_perm,
76-
resolver.clone(),
77-
context.clone(),
78-
));
79-
let auto_q: Box<dyn QuestionHandler> = Box::new(ClassifierQuestionHandler::new(
80-
classifier, ipc_q_arc, resolver, context, emitter,
81-
));
82-
(auto_perm, auto_q)
73+
let auto_perm: Box<dyn PermissionHandler> = Box::new(
74+
ClassifierPermissionHandler::new(
75+
classifier.clone(),
76+
ipc_perm,
77+
resolver.clone(),
78+
context.clone(),
79+
)
80+
.with_decision(decision.clone()),
81+
);
82+
let auto_q: Box<dyn QuestionHandler> = Box::new(
83+
ClassifierQuestionHandler::new(classifier, ipc_q_arc, resolver, context, emitter)
84+
.with_decision(decision.clone()),
85+
);
86+
(auto_perm, auto_q, decision)
8387
}

crates/loopal-agent-server/src/session_start.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ pub(crate) async fn start_session(
9494
));
9595
let decision_context =
9696
loopal_runtime::frontend::DecisionContext::with_cwd(cwd.to_string_lossy().into_owned());
97-
let (perm_handler, q_handler) = build_session_handlers(
97+
let (perm_handler, q_handler, decision_cell) = build_session_handlers(
9898
&config,
9999
&kernel,
100100
session_holder.clone(),
@@ -124,6 +124,7 @@ pub(crate) async fn start_session(
124124
session_dir_override.as_deref(),
125125
hub,
126126
decision_context,
127+
decision_cell,
127128
&preset_session_id,
128129
))
129130
.await?;

crates/loopal-agent-server/tests/suite/hub_harness.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ pub async fn build_hub_harness_with(
155155
Some(fixture.path()),
156156
&hub,
157157
loopal_runtime::frontend::DecisionContext::with_cwd("/tmp/test"),
158+
loopal_runtime::frontend::DecisionCell::new(loopal_decision_api::DecisionMode::Manual),
158159
"harness-session",
159160
),
160161
)

crates/loopal-agent-server/tests/suite/session_handlers_factory_test.rs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async fn manual_decision_yields_ipc_only_no_primary_connection() {
3939
let config = empty_config(DecisionMode::Manual);
4040
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
4141
let session = dummy_session();
42-
let (perm, _q) = build_session_handlers(
42+
let (perm, _q, _cell) = build_session_handlers(
4343
&config,
4444
&kernel,
4545
session,
@@ -63,7 +63,7 @@ async fn auto_decision_wraps_with_auto_handlers_and_falls_back() {
6363
let config = empty_config(DecisionMode::Classifier);
6464
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
6565
let session = dummy_session();
66-
let (perm, _q) = build_session_handlers(
66+
let (perm, _q, _cell) = build_session_handlers(
6767
&config,
6868
&kernel,
6969
session,
@@ -92,7 +92,7 @@ async fn manual_question_path_cancels_without_connection() {
9292
let config = empty_config(DecisionMode::Manual);
9393
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
9494
let session = dummy_session();
95-
let (_perm, q) = build_session_handlers(
95+
let (_perm, q, _cell) = build_session_handlers(
9696
&config,
9797
&kernel,
9898
session,
@@ -118,7 +118,7 @@ async fn auto_question_path_chains_fallback_when_provider_unresolvable() {
118118
let config = empty_config(DecisionMode::Classifier);
119119
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
120120
let session = dummy_session();
121-
let (_perm, q) = build_session_handlers(
121+
let (_perm, q, _cell) = build_session_handlers(
122122
&config,
123123
&kernel,
124124
session,
@@ -139,14 +139,40 @@ async fn auto_question_path_chains_fallback_when_provider_unresolvable() {
139139
);
140140
}
141141

142+
#[tokio::test]
143+
async fn decision_cell_switch_flips_manual_to_classifier_at_runtime() {
144+
let config = empty_config(DecisionMode::Manual);
145+
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
146+
let session = dummy_session();
147+
let (perm, _q, cell) = build_session_handlers(
148+
&config,
149+
&kernel,
150+
session,
151+
DecisionContext::with_cwd("/tmp/test"),
152+
);
153+
let manual = perm.decide("id1", "Bash", &serde_json::json!({})).await;
154+
assert!(
155+
manual.reason.contains("no primary connection"),
156+
"Manual cell must delegate to fallback, got: {}",
157+
manual.reason
158+
);
159+
cell.set(DecisionMode::Classifier);
160+
let classifier = perm.decide("id2", "Bash", &serde_json::json!({})).await;
161+
assert!(
162+
classifier.reason.contains("provider lookup failed"),
163+
"Classifier cell must run the classifier path, got: {}",
164+
classifier.reason
165+
);
166+
}
167+
142168
#[tokio::test]
143169
async fn agent_decision_falls_back_to_classifier_path_today() {
144170
// Agent mode is not yet implemented; factory must transparently fall
145171
// back to Classifier behaviour so existing setups keep working.
146172
let config = empty_config(DecisionMode::Agent);
147173
let kernel = Arc::new(Kernel::new(Settings::default()).unwrap());
148174
let session = dummy_session();
149-
let (perm, q) = build_session_handlers(
175+
let (perm, q, _cell) = build_session_handlers(
150176
&config,
151177
&kernel,
152178
session,

0 commit comments

Comments
 (0)