1use std::sync::Arc;
87use std::time::Duration;
88
89use serde_json::Value;
90
91use crate::agents::translator::claude::ClaudeTranslator;
92use crate::agents::translator::codex::CodexTranslator;
93use crate::agents::translator::gemini::GeminiTranslator;
94use crate::agents::translator::Translator;
95use crate::agents::types::AgentEvent;
96use crate::backend::blockcontroller;
97use crate::backend::blockcontroller::subprocess::argv::{
98 build_codex_side_question_argv, build_gemini_side_question_argv, build_side_question_argv,
99 model_flag_value, GEMINI_DENY_ALL_TOOLS_POLICY,
100};
101use crate::backend::mps;
102use crate::backend::obj::{Block, MetaMapType};
103use crate::backend::rpc::engine::WshRpcEngine;
104use crate::backend::rpc_types::{
105 AskSideQuestionResult, CommandAskSideQuestionData, COMMAND_ASK_SIDE_QUESTION,
106};
107use crate::backend::storage::filestore::FileStore;
108use crate::backend::storage::store::Store;
109
110use super::super::AppState;
111use super::input::{run_agent_turn, AgentTurnDeps, TurnRegistration};
112
113const COPIED_META_KEYS: &[&str] = &["cmd", "cmd:cwd", "cmd:env"];
117
118const MAX_WAIT: Duration = Duration::from_secs(120);
124const POLL_INTERVAL: Duration = Duration::from_millis(120);
130const MAX_READ_BYTES: i64 = 1024 * 1024;
133
134pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
135 let deps = AgentTurnDeps::from_state(state);
136 let mstore = state.mstore.clone();
137 let filestore = state.filestore.clone();
138 let broker = state.broker.clone();
139 let boot_id = state.boot_id.clone();
140
141 engine.register_typed(
142 COMMAND_ASK_SIDE_QUESTION,
143 move |cmd: CommandAskSideQuestionData, _ctx| {
144 let deps = deps.clone();
145 let mstore = mstore.clone();
146 let filestore = filestore.clone();
147 let broker = broker.clone();
148 let boot_id = boot_id.clone();
149 async move {
150 let request_id = uuid::Uuid::new_v4().to_string();
151 tracing::info!(
152 source_block_id = %cmd.block_id,
153 request_id = %request_id,
154 "AskSideQuestion (/btw)"
155 );
156
157 tokio::spawn(run_side_question(
164 deps,
165 mstore,
166 filestore,
167 broker,
168 boot_id,
169 cmd.block_id,
170 cmd.question,
171 cmd.context_snapshot,
172 request_id.clone(),
173 ));
174
175 Ok(AskSideQuestionResult { request_id })
176 }
177 },
178 );
179}
180
181fn btw_scope(source_block_id: &str, request_id: &str) -> String {
184 format!("block:{source_block_id}:btw:{request_id}")
185}
186
187fn build_prompt(context_snapshot: &str, question: &str) -> String {
192 if context_snapshot.trim().is_empty() {
193 question.to_string()
194 } else {
195 format!("{context_snapshot}\n\n{question}")
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202enum BtwProvider {
203 Claude,
204 Codex,
205 Gemini,
206}
207
208impl BtwProvider {
209 fn of(source_meta: &MetaMapType) -> Result<Self, String> {
212 match crate::backend::obj::meta_get_string(source_meta, "agentProvider", "").as_str() {
213 "" | "claude" => Ok(Self::Claude),
214 "codex" => Ok(Self::Codex),
215 "gemini" => Ok(Self::Gemini),
216 other => Err(format!("/btw isn't available for {other} agents")),
217 }
218 }
219
220 fn cli_command(self) -> &'static str {
221 match self {
222 Self::Claude => "claude",
223 Self::Codex => "codex",
224 Self::Gemini => "gemini",
225 }
226 }
227
228 fn translator(self) -> Box<dyn Translator> {
229 match self {
230 Self::Claude => Box::new(ClaudeTranslator::new()),
231 Self::Codex => Box::new(CodexTranslator::new()),
232 Self::Gemini => Box::new(GeminiTranslator::new()),
233 }
234 }
235}
236
237fn ensure_gemini_policy() -> Result<String, String> {
243 ensure_gemini_policy_in(&crate::backend::base::get_mux_data_dir().join("btw"))
244}
245
246fn ensure_gemini_policy_in(dir: &std::path::Path) -> Result<String, String> {
247 std::fs::create_dir_all(dir).map_err(|e| format!("btw: create {}: {e}", dir.display()))?;
248 let path = dir.join("gemini-deny-all-tools.toml");
249 if std::fs::read_to_string(&path).ok().as_deref() != Some(GEMINI_DENY_ALL_TOOLS_POLICY) {
250 let tmp = dir.join(format!(".gemini-deny-all-tools.{}.tmp", uuid::Uuid::new_v4()));
251 std::fs::write(&tmp, GEMINI_DENY_ALL_TOOLS_POLICY)
252 .and_then(|()| std::fs::rename(&tmp, &path))
253 .map_err(|e| {
254 let _ = std::fs::remove_file(&tmp);
255 format!("btw: write {}: {e}", path.display())
256 })?;
257 }
258 Ok(path.to_string_lossy().into_owned())
259}
260
261fn build_throwaway_meta(source_meta: &MetaMapType, provider: BtwProvider, gemini_policy: &str) -> MetaMapType {
267 let mut meta = MetaMapType::new();
268 for key in COPIED_META_KEYS {
269 if let Some(v) = source_meta.get(*key) {
270 meta.insert((*key).to_string(), v.clone());
271 }
272 }
273 if meta.get("cmd").is_none() {
274 meta.insert("cmd".to_string(), Value::String(provider.cli_command().to_string()));
275 }
276
277 let source_args: Option<Vec<String>> = match source_meta.get("cmd:args") {
278 Some(Value::Array(arr)) => Some(
279 arr.iter()
280 .filter_map(|v| v.as_str().map(|s| s.to_string()))
281 .collect(),
282 ),
283 _ => None,
284 };
285 let model = source_args.as_deref().and_then(model_flag_value);
286 let cli_args = match provider {
287 BtwProvider::Claude => build_side_question_argv(&super::input::container_argv(
294 source_args.unwrap_or_else(|| {
295 vec![
296 "-p".to_string(),
297 "--output-format".to_string(),
298 "stream-json".to_string(),
299 ]
300 }),
301 "claude",
302 )),
303 BtwProvider::Codex => build_codex_side_question_argv(model.as_deref()),
304 BtwProvider::Gemini => build_gemini_side_question_argv(gemini_policy, model.as_deref()),
305 };
306 meta.insert(
307 "cmd:args".to_string(),
308 Value::Array(cli_args.into_iter().map(Value::String).collect()),
309 );
310
311 meta
312}
313
314async fn spawn_throwaway_turn(
325 deps: &AgentTurnDeps,
326 mstore: &Arc<Store>,
327 boot_id: &Arc<str>,
328 source_block_id: &str,
329 prompt: String,
330) -> Result<(String, BtwProvider), String> {
331 let source: Block = mstore
332 .get(source_block_id)
333 .map_err(|e| format!("btw: load source block: {e}"))?
334 .ok_or_else(|| format!("btw: source block {source_block_id} not found"))?;
335
336 let provider = BtwProvider::of(&source.meta)?;
337 let gemini_policy = match provider {
338 BtwProvider::Gemini => ensure_gemini_policy()?,
339 _ => String::new(),
340 };
341 let meta = build_throwaway_meta(&source.meta, provider, &gemini_policy);
342
343 let block_id = uuid::Uuid::new_v4().to_string();
344 let mut block = Block {
345 oid: block_id.clone(),
346 meta,
347 ..Default::default()
348 };
349 mstore
350 .insert(&mut block)
351 .map_err(|e| format!("btw: insert throwaway block: {e}"))?;
352
353 let ctrl = blockcontroller::subprocess::SubprocessController::new(
354 String::new(), block_id.clone(),
356 Some(deps.broker.clone()),
357 None,
360 Some(mstore.clone()),
361 Some(deps.filestore_gate.clone()),
362 mstore.shared_agent_registry(),
363 boot_id.clone(),
364 );
365 let ctrl = Arc::new(ctrl);
366 ctrl.set_self_ref();
367 blockcontroller::register_controller(&block_id, ctrl);
368
369 if let Err(e) = run_agent_turn(
370 deps,
371 block_id.clone(),
372 prompt,
373 None,
374 TurnRegistration::Skip,
375 crate::backend::blockcontroller::health::TurnOrigin::System,
376 Vec::new(),
377 )
378 .await
379 {
380 tracing::warn!(
381 block_id = %block_id,
382 error = %e,
383 "btw: one-shot turn failed to start"
384 );
385 }
387
388 Ok((block_id, provider))
389}
390
391async fn run_side_question(
394 deps: AgentTurnDeps,
395 mstore: Arc<Store>,
396 filestore: Arc<FileStore>,
397 broker: Arc<mps::Broker>,
398 boot_id: Arc<str>,
399 source_block_id: String,
400 question: String,
401 context_snapshot: String,
402 request_id: String,
403) {
404 let scope = btw_scope(&source_block_id, &request_id);
405 let prompt = build_prompt(&context_snapshot, &question);
406
407 let (throwaway_block_id, provider) = match spawn_throwaway_turn(
408 &deps,
409 &mstore,
410 &boot_id,
411 &source_block_id,
412 prompt,
413 )
414 .await
415 {
416 Ok(spawned) => spawned,
417 Err(e) => {
418 publish_chunk(
419 &broker,
420 &scope,
421 &source_block_id,
422 &request_id,
423 &AgentEvent::Error { message: e },
424 true,
425 );
426 return;
427 }
428 };
429
430 drain_and_publish(
431 &broker,
432 &filestore,
433 provider.translator(),
434 &scope,
435 &source_block_id,
436 &request_id,
437 &throwaway_block_id,
438 )
439 .await;
440
441 cleanup_throwaway_block(&mstore, &broker, &throwaway_block_id);
442}
443
444fn cleanup_throwaway_block(mstore: &Arc<Store>, broker: &mps::Broker, throwaway_block_id: &str) {
450 crate::sagas::agent_teardown::discard(throwaway_block_id);
451 if let Err(e) = mstore.delete::<Block>(throwaway_block_id) {
452 tracing::warn!(
453 block_id = %throwaway_block_id,
454 error = %e,
455 "btw: failed to delete throwaway block row"
456 );
457 }
458 broker.purge_scope(&format!("block:{throwaway_block_id}"));
459}
460
461async fn drain_and_publish(
469 broker: &mps::Broker,
470 filestore: &FileStore,
471 mut translator: Box<dyn Translator>,
472 scope: &str,
473 source_block_id: &str,
474 request_id: &str,
475 throwaway_block_id: &str,
476) {
477 let mut offset: i64 = 0;
478 let deadline = tokio::time::Instant::now() + MAX_WAIT;
479
480 loop {
481 let mut saw_terminal = false;
482
483 if let Ok(Some(info)) = filestore.stat(throwaway_block_id, "output") {
484 let size = info.size;
485 if size > offset {
486 let want = (size - offset).min(MAX_READ_BYTES);
487 if let Ok((_, bytes)) = filestore.read_at(throwaway_block_id, "output", offset, want) {
488 if !bytes.is_empty() {
489 let consumed = match bytes.iter().rposition(|b| *b == b'\n') {
492 Some(idx) => idx + 1,
493 None => bytes.len(),
494 };
495 offset += consumed as i64;
496 let text = String::from_utf8_lossy(&bytes[..consumed]);
497 for line in text.lines() {
498 let line = line.trim();
499 if line.is_empty() {
500 continue;
501 }
502 let Ok(frame) = serde_json::from_str::<Value>(line) else {
503 continue;
504 };
505 for event in translator.translate(frame) {
506 let is_terminal =
507 matches!(event, AgentEvent::Done { .. } | AgentEvent::Error { .. });
508 publish_chunk(broker, scope, source_block_id, request_id, &event, is_terminal);
509 if is_terminal {
510 saw_terminal = true;
511 }
512 }
513 }
514 }
515 }
516 }
517 }
518
519 if saw_terminal {
520 return;
521 }
522
523 let still_running = blockcontroller::get_block_controller_status(throwaway_block_id)
524 .map(|s| s.shellprocstatus == blockcontroller::STATUS_RUNNING)
525 .unwrap_or(false);
526 if !still_running {
527 publish_chunk(
531 broker,
532 scope,
533 source_block_id,
534 request_id,
535 &AgentEvent::Error {
536 message: "agent exited without a final response".to_string(),
537 },
538 true,
539 );
540 return;
541 }
542
543 if tokio::time::Instant::now() >= deadline {
544 publish_chunk(
545 broker,
546 scope,
547 source_block_id,
548 request_id,
549 &AgentEvent::Error {
550 message: "timed out waiting for a response".to_string(),
551 },
552 true,
553 );
554 return;
555 }
556
557 tokio::time::sleep(POLL_INTERVAL).await;
558 }
559}
560
561fn publish_chunk(
564 broker: &mps::Broker,
565 scope: &str,
566 source_block_id: &str,
567 request_id: &str,
568 event: &AgentEvent,
569 done: bool,
570) {
571 let chunk = crate::backend::mps_events::BtwAnswerChunkEvent {
572 block_id: source_block_id.to_string(),
573 request_id: request_id.to_string(),
574 event: event.clone(),
575 done,
576 };
577 broker.publish_event::<crate::backend::mps_events::BtwAnswerChunk>(&chunk, vec![scope.to_string()], 0);
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn strings(values: &[&str]) -> Vec<String> {
585 values.iter().map(|v| (*v).to_string()).collect()
586 }
587
588 #[test]
591 fn btw_scope_matches_the_documented_format() {
592 assert_eq!(btw_scope("blk-1", "req-2"), "block:blk-1:btw:req-2");
593 }
594
595 #[test]
596 fn build_prompt_concatenates_snapshot_and_question() {
597 assert_eq!(
598 build_prompt("earlier conversation", "what did I just ask?"),
599 "earlier conversation\n\nwhat did I just ask?",
600 );
601 }
602
603 #[test]
604 fn build_prompt_with_no_snapshot_is_just_the_question() {
605 assert_eq!(build_prompt("", "hello?"), "hello?");
606 assert_eq!(build_prompt(" ", "hello?"), "hello?");
607 }
608
609 #[test]
610 fn throwaway_meta_excludes_every_agent_identity_key() {
611 let mut source = MetaMapType::new();
612 source.insert("cmd".to_string(), Value::String("claude".to_string()));
613 source.insert(
614 "cmd:args".to_string(),
615 Value::Array(vec![Value::String("-p".to_string())]),
616 );
617 source.insert("cmd:cwd".to_string(), Value::String("/tmp/work".to_string()));
618 source.insert("agentId".to_string(), Value::String("inst-1".to_string()));
619 source.insert("agentName".to_string(), Value::String("smike".to_string()));
620 source.insert("agentMode".to_string(), Value::String("container".to_string()));
621 source.insert("agent:sessionid".to_string(), Value::String("sid-1".to_string()));
622
623 let got = build_throwaway_meta(&source, BtwProvider::Claude, "");
624
625 assert!(!got.contains_key("agentId"));
626 assert!(!got.contains_key("agentName"));
627 assert!(!got.contains_key("agentMode"));
628 assert!(!got.contains_key("agent:sessionid"));
629 assert_eq!(got.get("cmd:cwd"), Some(&Value::String("/tmp/work".to_string())));
630 }
631
632 #[test]
633 fn throwaway_meta_carries_disallowedtools_and_max_turns() {
634 let mut source = MetaMapType::new();
635 source.insert(
636 "cmd:args".to_string(),
637 Value::Array(
638 strings(&["-p", "--output-format", "stream-json"])
639 .into_iter()
640 .map(Value::String)
641 .collect(),
642 ),
643 );
644 let got = build_throwaway_meta(&source, BtwProvider::Claude, "");
645 let args: Vec<String> = match got.get("cmd:args") {
646 Some(Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
647 other => panic!("expected cmd:args array, got {other:?}"),
648 };
649 assert!(args.iter().any(|a| a == "--disallowedTools"));
650 assert!(args.iter().any(|a| a == "--max-turns"));
651 assert!(!args.iter().any(|a| a == "--resume"));
652 }
653
654 #[test]
655 fn a_persistent_panes_control_protocol_flags_do_not_leak_into_the_one_shot_turn() {
656 let mut source = MetaMapType::new();
658 source.insert(
659 "cmd:args".to_string(),
660 Value::Array(
661 strings(&[
662 "-p", "--output-format", "stream-json", "--verbose",
663 "--input-format", "stream-json",
664 "--permission-prompt-tool", "stdio",
665 "--permission-mode", "default",
666 "--model", "opus", "--effort", "high",
667 "--add-dir", "/x",
668 ])
669 .into_iter()
670 .map(Value::String)
671 .collect(),
672 ),
673 );
674 let args = args_of(&build_throwaway_meta(&source, BtwProvider::Claude, ""));
675 assert!(!args.iter().any(|a| a == "--input-format"), "{args:?}");
676 assert!(!args.iter().any(|a| a == "--permission-prompt-tool"), "{args:?}");
677 assert!(args.iter().any(|a| a == "-p"), "{args:?}");
678 let pos = |f: &str| args.iter().position(|a| a == f).unwrap();
680 assert_eq!(args[pos("--model") + 1], "opus");
681 assert_eq!(args[pos("--effort") + 1], "high");
682 assert_eq!(args[pos("--add-dir") + 1], "/x");
683 assert!(args.iter().any(|a| a == "--disallowedTools"));
684 }
685
686 #[test]
687 fn throwaway_meta_defaults_cmd_and_args_when_source_has_none() {
688 let source = MetaMapType::new();
689 let got = build_throwaway_meta(&source, BtwProvider::Claude, "");
690 assert_eq!(got.get("cmd"), Some(&Value::String("claude".to_string())));
691 assert!(got.contains_key("cmd:args"));
692 }
693
694 fn args_of(meta: &MetaMapType) -> Vec<String> {
695 match meta.get("cmd:args") {
696 Some(Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
697 other => panic!("expected cmd:args array, got {other:?}"),
698 }
699 }
700
701 fn pane(provider: &str, cmd: &str, args: &[&str]) -> MetaMapType {
702 let mut source = MetaMapType::new();
703 source.insert("agentProvider".to_string(), Value::String(provider.to_string()));
704 source.insert("cmd".to_string(), Value::String(cmd.to_string()));
705 source.insert(
706 "cmd:args".to_string(),
707 Value::Array(strings(args).into_iter().map(Value::String).collect()),
708 );
709 source.insert("agentId".to_string(), Value::String("inst-1".to_string()));
710 source
711 }
712
713 #[test]
714 fn the_provider_comes_from_the_source_pane() {
715 assert_eq!(BtwProvider::of(&MetaMapType::new()), Ok(BtwProvider::Claude));
716 assert_eq!(BtwProvider::of(&pane("claude", "claude", &[])), Ok(BtwProvider::Claude));
717 assert_eq!(BtwProvider::of(&pane("codex", "codex", &[])), Ok(BtwProvider::Codex));
718 assert_eq!(BtwProvider::of(&pane("gemini", "gemini", &[])), Ok(BtwProvider::Gemini));
719 let err = BtwProvider::of(&pane("kimi", "kimi", &[])).unwrap_err();
720 assert!(err.contains("kimi"), "{err}");
721 }
722
723 #[test]
727 fn a_codex_pane_gets_a_tool_less_exec_on_its_own_model() {
728 let source = pane("codex", "/opt/codex", &["app-server", "--listen", "stdio://", "-m", "gpt-5"]);
729 let got = build_throwaway_meta(&source, BtwProvider::Codex, "");
730 assert_eq!(got.get("cmd"), Some(&Value::String("/opt/codex".to_string())));
731 assert!(!got.contains_key("agentId"));
732 let args = args_of(&got);
733 assert_eq!(&args[..2], &strings(&["exec", "--json"])[..]);
734 assert!(args.iter().any(|a| a == "--ignore-user-config"));
735 assert!(args.windows(2).any(|w| w == strings(&["-m", "gpt-5"])));
736 assert!(!args.iter().any(|a| a == "app-server" || a.contains("dangerously")));
737 }
738
739 #[test]
740 fn a_gemini_pane_gets_the_deny_all_policy_instead_of_yolo() {
741 let source = pane("gemini", "gemini", &["--output-format", "stream-json", "--yolo", "-p", "", "-m", "gemini-3-pro"]);
742 let got = build_throwaway_meta(&source, BtwProvider::Gemini, "/data/btw/policy.toml");
743 let args = args_of(&got);
744 assert!(args.windows(2).any(|w| w == strings(&["--policy", "/data/btw/policy.toml"])));
745 assert!(args.windows(2).any(|w| w == strings(&["-m", "gemini-3-pro"])));
746 assert!(!args.iter().any(|a| a == "--yolo"));
747 }
748
749 #[test]
750 fn the_gemini_policy_file_holds_the_deny_all_policy() {
751 let tmp = tempfile::tempdir().unwrap();
752 let dir = tmp.path().join("btw");
753 let path = ensure_gemini_policy_in(&dir).unwrap();
754 assert_eq!(std::fs::read_to_string(&path).unwrap(), GEMINI_DENY_ALL_TOOLS_POLICY);
755 std::fs::write(&path, "[[rule]]\ntoolName = \"*\"\ndecision = \"allow\"\n").unwrap();
757 assert_eq!(ensure_gemini_policy_in(&dir).unwrap(), path);
758 assert_eq!(std::fs::read_to_string(&path).unwrap(), GEMINI_DENY_ALL_TOOLS_POLICY);
759 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
760 }
761
762 #[tokio::test]
765 async fn ask_side_question_returns_a_request_id_without_blocking() {
766 let state = crate::server::tests::test_state();
772
773 let mut block = Block {
779 oid: "src-block".to_string(),
780 ..Default::default()
781 };
782 state.mstore.insert(&mut block).unwrap();
783
784 let (engine, mut rx) = WshRpcEngine::new();
785 register(&engine, &state);
786
787 let req_id = format!("test-{}", uuid::Uuid::new_v4());
788 let msg = crate::backend::rpc_types::RpcMessage {
789 command: COMMAND_ASK_SIDE_QUESTION.to_string(),
790 reqid: req_id.clone(),
791 data: Some(serde_json::json!({
792 "block_id": "src-block",
793 "question": "what does this do?",
794 "context_snapshot": "",
795 })),
796 ..Default::default()
797 };
798 engine.handle_message(msg);
799
800 let resp = tokio::time::timeout(Duration::from_secs(5), rx.recv())
801 .await
802 .expect("handler timed out — RPC must not block on the turn")
803 .expect("output channel closed");
804 assert_eq!(resp.resid, req_id);
805 assert!(resp.error.is_empty(), "handler returned error: {}", resp.error);
806 let result: AskSideQuestionResult =
807 serde_json::from_value(resp.data.unwrap()).expect("response deserialize");
808 assert!(!result.request_id.is_empty());
809 assert!(uuid::Uuid::parse_str(&result.request_id).is_ok());
810 }
811}