1use std::collections::HashMap;
10use std::path::Path;
11use std::sync::Arc;
12
13use tokio::sync::{mpsc, RwLock};
14
15use crate::agent_engine::{
16 process_with_agent_profile_guarded_mode, AgentEvent, AgentRequestContext,
17};
18use crate::chat_turn_queue::PendingMessage;
19use crate::config::Config;
20use crate::hooks::HookManager;
21use crate::internal::channels::channel_adapter::ChannelRegistry;
22use crate::internal::runtime::{ExecutionContext, ExecutionResult, RunExecutor};
23use crate::internal::storage::db::{call_blocking, Database, StoredMessage};
24use crate::memory::MemoryManager;
25use crate::memory_backend::{MemoryBackend, MemoryMcpClient};
26use crate::runtime::AppState;
27use crate::skill_management::{archive_skill, install_skill, SkillInstallOutcome};
28use crate::skills::{SkillAvailability, SkillManager};
29use crate::tools::ToolRegistry;
30use crate::{embedding, llm};
31use microclaw_core::run_protocol::{
32 AgentProfile, RunRequest, RuntimeCapabilities, RuntimeControl, RuntimeError, RuntimeErrorCode,
33 SessionId,
34};
35use microclaw_core::runtime_event::RuntimeEventEnvelope;
36
37pub const HEADLESS_CHANNEL: &str = "headless";
38pub const WORK_CHANNEL: &str = "work";
39
40#[derive(Debug, Clone)]
41pub struct HeadlessRunRequest {
42 pub prompt: String,
43 pub session: Option<String>,
44 pub sender_name: String,
45 pub run_id: Option<String>,
46 pub caller_channel: String,
47}
48
49impl HeadlessRunRequest {
50 pub fn cli(prompt: String, session: Option<String>) -> Self {
51 Self {
52 prompt,
53 session,
54 sender_name: "cli".into(),
55 run_id: None,
56 caller_channel: HEADLESS_CHANNEL.into(),
57 }
58 }
59
60 pub fn work(prompt: String, session: Option<String>, run_id: String) -> Self {
61 Self {
62 prompt,
63 session,
64 sender_name: "work".into(),
65 run_id: Some(run_id),
66 caller_channel: WORK_CHANNEL.into(),
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
72pub struct HeadlessRunResult {
73 pub run_id: String,
74 pub chat_id: i64,
75 pub response: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct HeadlessSubagentRun {
81 pub run_id: String,
82 pub parent_run_id: Option<String>,
83 pub depth: u32,
84 pub label: Option<String>,
85 pub task: String,
86 pub status: String,
87 pub progress: Option<String>,
88 pub result: Option<String>,
89 pub error: Option<String>,
90 pub created_at: String,
91 pub started_at: Option<String>,
92 pub finished_at: Option<String>,
93 pub cancel_requested: bool,
94}
95
96#[derive(Clone)]
102pub struct HeadlessRuntime {
103 state: Arc<AppState>,
104}
105
106pub struct HeadlessRunExecutor {
112 runtime: HeadlessRuntime,
113 caller_channel: String,
114}
115
116impl HeadlessRunExecutor {
117 pub fn new(runtime: HeadlessRuntime, caller_channel: impl Into<String>) -> Self {
118 Self {
119 runtime,
120 caller_channel: caller_channel.into(),
121 }
122 }
123}
124
125#[async_trait::async_trait]
126impl RunExecutor for HeadlessRunExecutor {
127 async fn execute(
128 &self,
129 profile: AgentProfile,
130 request: RunRequest,
131 mut context: ExecutionContext,
132 ) -> Result<ExecutionResult, RuntimeError> {
133 let session = request
134 .session_id
135 .clone()
136 .unwrap_or_else(|| SessionId::new("default"));
137 let (legacy_event_tx, mut legacy_event_rx) = mpsc::unbounded_channel();
138 let run = self.runtime.run_with_profile(
139 HeadlessRunRequest {
140 prompt: request.prompt,
141 session: Some(session.to_string()),
142 sender_name: self.caller_channel.clone(),
143 run_id: Some(context.run_id().to_string()),
144 caller_channel: self.caller_channel.clone(),
145 },
146 profile,
147 Some(legacy_event_tx),
148 );
149 tokio::pin!(run);
150
151 let result = loop {
152 tokio::select! {
153 result = &mut run => break result,
154 event = legacy_event_rx.recv() => {
155 if let Some(envelope) = event {
156 forward_runtime_event(&context, envelope.event)?;
157 }
158 }
159 control = context.next_control_request() => {
160 if let Some(control) = control {
161 match control.control().clone() {
162 RuntimeControl::Cancel { .. } => {
163 let cancelled = self.runtime
164 .cancel_session_for_channel(&self.caller_channel, session.as_str())
165 .await
166 .map_err(runtime_internal_error)?;
167 if cancelled > 0 {
168 control.accept();
169 } else {
170 control.reject(RuntimeError {
171 code: RuntimeErrorCode::Unavailable,
172 message: "the run finished before cancellation was accepted".into(),
173 retryable: false,
174 });
175 }
176 }
177 RuntimeControl::Steer { message, .. } => {
178 let accepted = self.runtime
179 .steer_session_for_channel(
180 &self.caller_channel,
181 session.as_str(),
182 &message,
183 )
184 .await
185 .map_err(runtime_internal_error)?;
186 if accepted {
187 control.accept();
188 } else {
189 control.reject(RuntimeError {
190 code: RuntimeErrorCode::Unavailable,
191 message: "the run finished before the update could be queued".into(),
192 retryable: false,
193 });
194 }
195 }
196 RuntimeControl::ResolveApproval { .. } => {
197 control.reject(RuntimeError {
198 code: RuntimeErrorCode::Unavailable,
199 message: "the current Agent Engine approval bridge is unavailable"
200 .into(),
201 retryable: false,
202 });
203 }
204 }
205 }
206 }
207 }
208 }
209 .map_err(runtime_internal_error)?;
210
211 while let Ok(envelope) = legacy_event_rx.try_recv() {
212 forward_runtime_event(&context, envelope.event)?;
213 }
214
215 let mut output = ExecutionResult::new(session, result.response);
216 output.metadata.insert(
217 "chat_id".into(),
218 serde_json::Value::Number(result.chat_id.into()),
219 );
220 Ok(output)
221 }
222
223 fn capabilities(&self) -> RuntimeCapabilities {
224 RuntimeCapabilities {
225 streaming: true,
226 cancellation: true,
227 steering: true,
228 approvals: false,
229 skills: true,
230 subagents: true,
231 remote_workers: false,
232 }
233 }
234}
235
236fn forward_runtime_event(
237 context: &ExecutionContext,
238 event: microclaw_core::runtime_event::RuntimeEvent,
239) -> Result<(), RuntimeError> {
240 if matches!(
241 event,
242 microclaw_core::runtime_event::RuntimeEvent::FinalResponse { .. }
243 | microclaw_core::runtime_event::RuntimeEvent::Cancelled { .. }
244 ) {
245 return Ok(());
246 }
247 context.emit(event)
248}
249
250fn runtime_internal_error(error: impl std::fmt::Display) -> RuntimeError {
251 RuntimeError {
252 code: RuntimeErrorCode::Internal,
253 message: error.to_string(),
254 retryable: false,
255 }
256}
257
258impl HeadlessRuntime {
259 pub fn skill_catalog(&self, include_unavailable: bool) -> Vec<SkillAvailability> {
262 self.state
263 .skills
264 .discover_skills_with_status(include_unavailable)
265 }
266
267 pub fn reload_skill_catalog(&self, include_unavailable: bool) -> Vec<SkillAvailability> {
269 self.state.skills.reload();
270 self.skill_catalog(include_unavailable)
271 }
272
273 pub fn set_skill_enabled(&self, name: &str, enabled: bool) -> Result<(), String> {
275 self.state.skills.set_enabled(name, enabled)?;
276 self.state.skills.reload();
277 Ok(())
278 }
279
280 pub async fn install_skill(&self, reference: &str) -> Result<SkillInstallOutcome, String> {
282 install_skill(&self.state.config, &self.state.skills, reference).await
283 }
284
285 pub fn archive_skill(&self, name: &str) -> Result<std::path::PathBuf, String> {
287 archive_skill(&self.state.skills, name)
288 }
289
290 pub fn subagent_runs(
292 &self,
293 session: &str,
294 limit: usize,
295 ) -> anyhow::Result<Vec<HeadlessSubagentRun>> {
296 let external_chat_id = format!("headless:{}", session.trim());
297 let Some(chat_id) = self
298 .state
299 .db
300 .find_chat_id(HEADLESS_CHANNEL, &external_chat_id)?
301 else {
302 return Ok(Vec::new());
303 };
304 Ok(self
305 .state
306 .db
307 .list_subagent_runs(chat_id, limit.clamp(1, 1_000))?
308 .into_iter()
309 .map(|run| HeadlessSubagentRun {
310 run_id: run.run_id,
311 parent_run_id: run.parent_run_id,
312 depth: run.depth.max(0) as u32,
313 label: run.label,
314 task: run.task,
315 status: run.status,
316 progress: run.progress_text,
317 result: run.result_text,
318 error: run.error_text,
319 created_at: run.created_at,
320 started_at: run.started_at,
321 finished_at: run.finished_at,
322 cancel_requested: run.cancel_requested,
323 })
324 .collect())
325 }
326
327 pub fn cancel_subagent(&self, session: &str, run_id: &str) -> anyhow::Result<bool> {
329 let external_chat_id = format!("headless:{}", session.trim());
330 let Some(chat_id) = self
331 .state
332 .db
333 .find_chat_id(HEADLESS_CHANNEL, &external_chat_id)?
334 else {
335 return Ok(false);
336 };
337 Ok(crate::tools::subagents::request_subagent_cancel(
338 &self.state.config,
339 &self.state.db,
340 chat_id,
341 run_id,
342 )?)
343 }
344
345 pub async fn load(mut config: Config) -> anyhow::Result<Self> {
350 let data_root = config.data_root_dir();
351 let runtime_data_dir = config.runtime_data_dir();
352 let skills_data_dir = config.skills_data_dir();
353 crate::builtin_skills::ensure_builtin_skills(Path::new(&skills_data_dir))?;
354
355 let db = Database::new(&runtime_data_dir)?;
356 let memory = MemoryManager::new(&runtime_data_dir);
357 let skills = SkillManager::from_skills_and_runtime(&skills_data_dir, &runtime_data_dir)
358 .with_config_verification(&config);
359 let mcp_manager = crate::mcp::McpManager::from_config_paths(
360 &crate::mcp::collect_config_paths(&data_root),
361 config.mcp_request_timeout_secs(),
362 )
363 .await;
364
365 config.data_dir = runtime_data_dir;
366 config.skills_dir = Some(skills_data_dir);
367 Ok(Self::new(config, db, memory, skills, mcp_manager))
368 }
369
370 pub fn new(
371 config: Config,
372 db: Database,
373 memory: MemoryManager,
374 skills: SkillManager,
375 mcp_manager: crate::mcp::McpManager,
376 ) -> Self {
377 let db = Arc::new(db);
378 let llm = llm::create_provider(&config);
379 let embedding = embedding::create_provider(&config);
380 let channel_registry = Arc::new(ChannelRegistry::new());
381
382 let memory_backend = Arc::new(MemoryBackend::new(
383 db.clone(),
384 MemoryMcpClient::discover(&mcp_manager),
385 &config.data_dir,
386 ));
387 let mut tools = ToolRegistry::new(
388 &config,
389 channel_registry.clone(),
390 db.clone(),
391 memory_backend.clone(),
392 );
393 tools.register_mcp_tools(&mcp_manager);
394
395 let chat_turn_queue = Arc::new(crate::chat_turn_queue::ChatTurnQueue::new(
396 config.chat_turn_queue_max_pending,
397 ));
398 let (skill_review_queue, _skill_review_worker) =
399 crate::skill_review::build_skill_review_channel();
400 let state = Arc::new(AppState {
401 config: config.clone(),
402 channel_registry,
403 db: db.clone(),
404 memory: Arc::new(memory),
405 skills: Arc::new(skills),
406 hooks: Arc::new(HookManager::from_config(&config).with_db(db.clone())),
407 llm: Arc::from(llm),
408 llm_provider_overrides: Arc::new(RwLock::new(HashMap::new())),
409 llm_model_overrides: Arc::new(RwLock::new(HashMap::new())),
410 embedding,
411 memory_backend,
412 tools: Arc::new(tools),
413 chat_turn_queue,
414 skill_review_queue,
415 metric_exporter: None,
416 trace_exporter: None,
417 log_exporter: None,
418 });
419 Self { state }
420 }
421
422 pub fn into_embedded_runtime(
424 self,
425 caller_channel: impl Into<String>,
426 max_concurrent_runs: usize,
427 ) -> Result<crate::internal::runtime::Runtime, crate::internal::runtime::RuntimeBuildError>
428 {
429 crate::internal::runtime::Runtime::builder()
430 .executor(HeadlessRunExecutor::new(self, caller_channel))
431 .max_concurrent_runs(max_concurrent_runs)
432 .build()
433 }
434
435 pub async fn run(
436 &self,
437 request: HeadlessRunRequest,
438 event_tx: Option<mpsc::UnboundedSender<RuntimeEventEnvelope>>,
439 ) -> anyhow::Result<HeadlessRunResult> {
440 self.run_with_profile(request, AgentProfile::default(), event_tx)
441 .await
442 }
443
444 pub async fn run_with_profile(
445 &self,
446 request: HeadlessRunRequest,
447 profile: AgentProfile,
448 event_tx: Option<mpsc::UnboundedSender<RuntimeEventEnvelope>>,
449 ) -> anyhow::Result<HeadlessRunResult> {
450 let session_name = request
451 .session
452 .clone()
453 .unwrap_or_else(|| "default".to_string());
454 let chat_id = self.resolve_session_chat_id(&session_name).await?;
455 let caller_channel = request.caller_channel;
456
457 let stored = StoredMessage {
458 id: uuid::Uuid::new_v4().to_string(),
459 chat_id,
460 sender_name: request.sender_name,
461 content: request.prompt,
462 is_from_bot: false,
463 timestamp: chrono::Utc::now().to_rfc3339(),
464 };
465 let _ = call_blocking(self.state.db.clone(), move |db| db.store_message(&stored)).await;
466
467 let run_id = request
468 .run_id
469 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
470 let (raw_event_tx, bridge) = match event_tx {
471 Some(envelope_tx) => {
472 let (raw_tx, raw_rx) = mpsc::unbounded_channel();
473 let bridge = spawn_event_envelope_bridge(run_id.clone(), raw_rx, envelope_tx);
474 (Some(raw_tx), Some(bridge))
475 }
476 None => (None, None),
477 };
478 let response = process_with_agent_profile_guarded_mode(
479 &self.state,
480 AgentRequestContext {
481 caller_channel: &caller_channel,
482 chat_id,
483 chat_type: "private",
484 },
485 None,
486 None,
487 raw_event_tx.as_ref(),
488 None,
489 false,
490 None,
491 profile,
492 )
493 .await;
494 drop(raw_event_tx);
495 if let Some(bridge) = bridge {
496 bridge.await?;
497 }
498 let response = response?;
499
500 let bot_msg = StoredMessage {
501 id: uuid::Uuid::new_v4().to_string(),
502 chat_id,
503 sender_name: "microclaw".to_string(),
504 content: response.clone(),
505 is_from_bot: true,
506 timestamp: chrono::Utc::now().to_rfc3339(),
507 };
508 let _ = call_blocking(self.state.db.clone(), move |db| db.store_message(&bot_msg)).await;
509
510 Ok(HeadlessRunResult {
511 run_id,
512 chat_id,
513 response,
514 })
515 }
516
517 pub async fn cancel_session(&self, session: &str) -> anyhow::Result<usize> {
523 self.cancel_session_for_channel(HEADLESS_CHANNEL, session)
524 .await
525 }
526
527 pub async fn cancel_work_session(&self, session: &str) -> anyhow::Result<usize> {
528 self.cancel_session_for_channel(WORK_CHANNEL, session).await
529 }
530
531 async fn cancel_session_for_channel(
532 &self,
533 channel: &str,
534 session: &str,
535 ) -> anyhow::Result<usize> {
536 let chat_id = self.resolve_session_chat_id(session).await?;
537 Ok(crate::run_control::abort_runs(channel, chat_id).await)
538 }
539
540 pub async fn steer_session(&self, session: &str, content: &str) -> anyhow::Result<bool> {
546 self.steer_session_for_channel(HEADLESS_CHANNEL, session, content)
547 .await
548 }
549
550 pub async fn steer_work_session(&self, session: &str, content: &str) -> anyhow::Result<bool> {
551 self.steer_session_for_channel(WORK_CHANNEL, session, content)
552 .await
553 }
554
555 async fn steer_session_for_channel(
556 &self,
557 channel: &str,
558 session: &str,
559 content: &str,
560 ) -> anyhow::Result<bool> {
561 let content = content.trim();
562 if content.is_empty() {
563 anyhow::bail!("steering update must not be empty");
564 }
565 let chat_id = self.resolve_session_chat_id(session).await?;
566 let message_id = uuid::Uuid::new_v4().to_string();
567 let timestamp = chrono::Utc::now().to_rfc3339();
568 let pending = PendingMessage {
569 sender_name: "work".into(),
570 content: content.to_string(),
571 message_id: message_id.clone(),
572 timestamp: timestamp.clone(),
573 };
574 if !self
575 .state
576 .chat_turn_queue
577 .enqueue_if_busy(channel, chat_id, pending)
578 .await
579 {
580 return Ok(false);
581 }
582
583 let stored = StoredMessage {
584 id: message_id,
585 chat_id,
586 sender_name: "work".into(),
587 content: content.to_string(),
588 is_from_bot: false,
589 timestamp,
590 };
591 call_blocking(self.state.db.clone(), move |db| db.store_message(&stored)).await?;
592 Ok(true)
593 }
594
595 async fn resolve_session_chat_id(&self, session: &str) -> anyhow::Result<i64> {
596 let external_chat_id = format!("headless:{session}");
597 let title = format!("headless-{session}");
598 Ok(call_blocking(self.state.db.clone(), move |db| {
599 db.resolve_or_create_chat_id(
600 HEADLESS_CHANNEL,
601 &external_chat_id,
602 Some(&title),
603 "headless",
604 )
605 })
606 .await?)
607 }
608}
609
610fn spawn_event_envelope_bridge(
611 run_id: String,
612 mut raw_rx: mpsc::UnboundedReceiver<AgentEvent>,
613 envelope_tx: mpsc::UnboundedSender<RuntimeEventEnvelope>,
614) -> tokio::task::JoinHandle<()> {
615 tokio::spawn(async move {
616 let mut sequence = 0_u64;
617 while let Some(event) = raw_rx.recv().await {
618 sequence = sequence.saturating_add(1);
619 if envelope_tx
620 .send(RuntimeEventEnvelope::new(&run_id, sequence, event))
621 .is_err()
622 {
623 break;
624 }
625 }
626 })
627}
628
629#[allow(clippy::too_many_arguments)]
630pub async fn run_once(
631 config: Config,
632 db: Database,
633 memory: MemoryManager,
634 skills: SkillManager,
635 mcp_manager: crate::mcp::McpManager,
636 prompt: String,
637 session: Option<String>,
638 json: bool,
639) -> anyhow::Result<i32> {
640 let runtime = HeadlessRuntime::new(config, db, memory, skills, mcp_manager)
641 .into_embedded_runtime(HEADLESS_CHANNEL, 1)?;
642 let mut request = RunRequest::new(prompt);
643 request.session_id = session.map(SessionId::new);
644 let result = runtime
645 .agent(AgentProfile {
646 name: "MicroClaw CLI".into(),
647 ..AgentProfile::default()
648 })
649 .run(request)
650 .result()
651 .await;
652
653 match result {
654 Ok(result) => {
655 let chat_id = result
656 .metadata
657 .get("chat_id")
658 .and_then(serde_json::Value::as_i64);
659 if json {
660 println!(
661 "{}",
662 serde_json::json!({
663 "ok": true,
664 "response": result.final_text,
665 "chat_id": chat_id,
666 "run_id": result.run_id.to_string(),
667 })
668 );
669 } else {
670 println!("{}", result.final_text);
671 }
672 Ok(0)
673 }
674 Err(e) => {
675 if json {
676 println!(
677 "{}",
678 serde_json::json!({"ok": false, "error": e.to_string()})
679 );
680 } else {
681 eprintln!("Error: {e}");
682 }
683 Ok(1)
684 }
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use crate::config::WorkingDirIsolation;
692 use crate::llm::LlmProvider;
693 use microclaw_core::error::MicroClawError;
694 use microclaw_core::llm_types::{
695 Message, MessagesResponse, ResponseContentBlock, ToolDefinition,
696 };
697 use microclaw_core::runtime_event::RuntimeEvent;
698
699 static RUNTIME_TEST_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
700 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
701
702 struct FakeLlm;
703
704 struct SlowLlm;
705
706 struct CapturingLlm {
707 system_prompt: Arc<std::sync::Mutex<String>>,
708 }
709
710 struct ToolCapturingLlm {
711 tools: Arc<std::sync::Mutex<Vec<String>>>,
712 }
713
714 struct SteeringLlm {
715 calls: Arc<std::sync::atomic::AtomicUsize>,
716 first_call_started: Arc<tokio::sync::Notify>,
717 saw_update: Arc<std::sync::atomic::AtomicBool>,
718 }
719
720 #[async_trait::async_trait]
721 impl LlmProvider for FakeLlm {
722 async fn send_message(
723 &self,
724 _system: &str,
725 _messages: Vec<Message>,
726 _tools: Option<Vec<ToolDefinition>>,
727 ) -> Result<MessagesResponse, MicroClawError> {
728 Ok(MessagesResponse {
729 content: vec![ResponseContentBlock::Text {
730 text: "real agent loop response".into(),
731 }],
732 stop_reason: Some("end_turn".into()),
733 usage: None,
734 })
735 }
736 }
737
738 #[async_trait::async_trait]
739 impl LlmProvider for SlowLlm {
740 async fn send_message(
741 &self,
742 _system: &str,
743 _messages: Vec<Message>,
744 _tools: Option<Vec<ToolDefinition>>,
745 ) -> Result<MessagesResponse, MicroClawError> {
746 tokio::time::sleep(std::time::Duration::from_secs(30)).await;
747 unreachable!("the cancellation test must stop the pending model call")
748 }
749 }
750
751 #[async_trait::async_trait]
752 impl LlmProvider for CapturingLlm {
753 async fn send_message(
754 &self,
755 system: &str,
756 _messages: Vec<Message>,
757 _tools: Option<Vec<ToolDefinition>>,
758 ) -> Result<MessagesResponse, MicroClawError> {
759 *self.system_prompt.lock().unwrap() = system.to_string();
760 Ok(MessagesResponse {
761 content: vec![ResponseContentBlock::Text {
762 text: "profile applied".into(),
763 }],
764 stop_reason: Some("end_turn".into()),
765 usage: None,
766 })
767 }
768 }
769
770 #[async_trait::async_trait]
771 impl LlmProvider for ToolCapturingLlm {
772 async fn send_message(
773 &self,
774 _system: &str,
775 _messages: Vec<Message>,
776 tools: Option<Vec<ToolDefinition>>,
777 ) -> Result<MessagesResponse, MicroClawError> {
778 *self.tools.lock().unwrap() = tools
779 .unwrap_or_default()
780 .into_iter()
781 .map(|tool| tool.name)
782 .collect();
783 Ok(MessagesResponse {
784 content: vec![ResponseContentBlock::Text {
785 text: "policy applied".into(),
786 }],
787 stop_reason: Some("end_turn".into()),
788 usage: None,
789 })
790 }
791 }
792
793 #[async_trait::async_trait]
794 impl LlmProvider for SteeringLlm {
795 async fn send_message(
796 &self,
797 _system: &str,
798 messages: Vec<Message>,
799 _tools: Option<Vec<ToolDefinition>>,
800 ) -> Result<MessagesResponse, MicroClawError> {
801 let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
802 if call == 0 {
803 self.first_call_started.notify_one();
804 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
805 return Ok(MessagesResponse {
806 content: vec![ResponseContentBlock::ToolUse {
807 id: "todo-read-1".into(),
808 name: "todo_read".into(),
809 input: serde_json::json!({}),
810 thought_signature: None,
811 }],
812 stop_reason: Some("tool_use".into()),
813 usage: None,
814 });
815 }
816 let rendered = format!("{messages:?}");
817 self.saw_update.store(
818 rendered.contains("Focus on the parser first"),
819 std::sync::atomic::Ordering::SeqCst,
820 );
821 Ok(MessagesResponse {
822 content: vec![ResponseContentBlock::Text {
823 text: "steering applied".into(),
824 }],
825 stop_reason: Some("end_turn".into()),
826 usage: None,
827 })
828 }
829 }
830
831 fn runtime_with_fake_llm(base: &Path) -> HeadlessRuntime {
832 runtime_with_llm(base, Box::new(FakeLlm))
833 }
834
835 fn runtime_with_llm(base: &Path, llm: Box<dyn LlmProvider>) -> HeadlessRuntime {
836 let runtime_dir = base.join("runtime");
837 std::fs::create_dir_all(&runtime_dir).unwrap();
838 let mut config = Config::test_defaults();
839 config.data_dir = runtime_dir.display().to_string();
840 config.working_dir = base.display().to_string();
841 config.working_dir_isolation = WorkingDirIsolation::Shared;
842 let runtime_dir = runtime_dir.display().to_string();
843 let db = Arc::new(Database::new(&runtime_dir).unwrap());
844 let memory_backend = Arc::new(MemoryBackend::local_only(db.clone()));
845 let channel_registry = Arc::new(ChannelRegistry::new());
846 HeadlessRuntime {
847 state: Arc::new(AppState {
848 config: config.clone(),
849 channel_registry: channel_registry.clone(),
850 db: db.clone(),
851 memory: Arc::new(MemoryManager::new(&runtime_dir)),
852 skills: Arc::new(SkillManager::from_skills_dir(&config.skills_data_dir())),
853 hooks: Arc::new(HookManager::from_config(&config)),
854 llm: Arc::from(llm),
855 llm_provider_overrides: Arc::new(RwLock::new(HashMap::new())),
856 llm_model_overrides: Arc::new(RwLock::new(HashMap::new())),
857 embedding: None,
858 memory_backend: memory_backend.clone(),
859 tools: Arc::new(ToolRegistry::new(
860 &config,
861 channel_registry,
862 db,
863 memory_backend,
864 )),
865 chat_turn_queue: Arc::new(crate::chat_turn_queue::ChatTurnQueue::new(20)),
866 skill_review_queue: crate::skill_review::build_skill_review_channel().0,
867 metric_exporter: None,
868 trace_exporter: None,
869 log_exporter: None,
870 }),
871 }
872 }
873
874 #[tokio::test]
875 async fn event_bridge_adds_stable_run_id_and_monotonic_sequence() {
876 let (raw_tx, raw_rx) = mpsc::unbounded_channel();
877 let (envelope_tx, mut envelope_rx) = mpsc::unbounded_channel();
878 let bridge = spawn_event_envelope_bridge("work-run-1".into(), raw_rx, envelope_tx);
879
880 raw_tx
881 .send(RuntimeEvent::Iteration { iteration: 1 })
882 .unwrap();
883 raw_tx
884 .send(RuntimeEvent::FinalResponse {
885 text: "done".into(),
886 })
887 .unwrap();
888 drop(raw_tx);
889 bridge.await.unwrap();
890
891 let first = envelope_rx.recv().await.unwrap();
892 let second = envelope_rx.recv().await.unwrap();
893 assert_eq!(first.run_id, "work-run-1");
894 assert_eq!(first.sequence, 1);
895 assert_eq!(second.run_id, "work-run-1");
896 assert_eq!(second.sequence, 2);
897 assert!(envelope_rx.recv().await.is_none());
898 }
899
900 #[tokio::test]
901 async fn reusable_runtime_executes_real_agent_loop_and_emits_envelopes() {
902 let _guard = RUNTIME_TEST_LOCK.lock().await;
903 let directory = tempfile::tempdir().unwrap();
904 let runtime = runtime_with_fake_llm(directory.path());
905 let (event_tx, mut event_rx) = mpsc::unbounded_channel();
906
907 let result = runtime
908 .run(
909 HeadlessRunRequest::work(
910 "answer through the shared loop".into(),
911 Some("work-test".into()),
912 "work-run-test".into(),
913 ),
914 Some(event_tx),
915 )
916 .await
917 .unwrap();
918 let mut events = Vec::new();
919 while let Some(event) = event_rx.recv().await {
920 events.push(event);
921 }
922
923 assert_eq!(result.run_id, "work-run-test");
924 assert_eq!(result.response, "real agent loop response");
925 assert!(!events.is_empty());
926 assert!(events.iter().enumerate().all(|(index, envelope)| {
927 envelope.run_id == "work-run-test" && envelope.sequence == index as u64 + 1
928 }));
929 assert!(events.iter().any(|envelope| matches!(
930 envelope.event,
931 RuntimeEvent::FinalResponse { ref text } if text == "real agent loop response"
932 )));
933 }
934
935 #[tokio::test]
936 async fn durable_subagents_are_observable_and_cancellable_through_headless_runtime() {
937 use crate::storage::db::subagents::CreateSubagentRunParams;
938
939 let _guard = RUNTIME_TEST_LOCK.lock().await;
940 let directory = tempfile::tempdir().unwrap();
941 let runtime = runtime_with_fake_llm(directory.path());
942 let chat_id = runtime
943 .resolve_session_chat_id("sdk-session")
944 .await
945 .unwrap();
946 runtime
947 .state
948 .db
949 .create_subagent_run(CreateSubagentRunParams {
950 run_id: "delegated-1",
951 parent_run_id: Some("main-1"),
952 depth: 1,
953 token_budget: 10_000,
954 chat_id,
955 caller_channel: HEADLESS_CHANNEL,
956 task: "inspect the parser",
957 context: "",
958 provider: "test",
959 model: "test",
960 label: Some("parser review"),
961 })
962 .unwrap();
963
964 let tasks = runtime.subagent_runs("sdk-session", 20).unwrap();
965 assert_eq!(tasks.len(), 1);
966 assert_eq!(tasks[0].parent_run_id.as_deref(), Some("main-1"));
967 assert_eq!(tasks[0].depth, 1);
968 assert_eq!(tasks[0].label.as_deref(), Some("parser review"));
969 assert!(runtime
970 .cancel_subagent("sdk-session", "delegated-1")
971 .unwrap());
972 assert!(runtime.subagent_runs("sdk-session", 20).unwrap()[0].cancel_requested);
973 }
974
975 #[tokio::test]
976 async fn embedded_agent_profile_applies_prompt_and_selected_skills() {
977 let _guard = RUNTIME_TEST_LOCK.lock().await;
978 let directory = tempfile::tempdir().unwrap();
979 let captured = Arc::new(std::sync::Mutex::new(String::new()));
980 let headless = runtime_with_llm(
981 directory.path(),
982 Box::new(CapturingLlm {
983 system_prompt: captured.clone(),
984 }),
985 );
986 let skill_dir = headless.state.skills.skills_dir().join("rust-review");
987 std::fs::create_dir_all(&skill_dir).unwrap();
988 std::fs::write(
989 skill_dir.join("SKILL.md"),
990 "---\nname: rust-review\ndescription: Review Rust\n---\nCHECK_AGENT_SKILL_BODY\n",
991 )
992 .unwrap();
993 let runtime = headless.into_embedded_runtime(WORK_CHANNEL, 1).unwrap();
994 let profile = AgentProfile {
995 name: "reviewer".into(),
996 system_prompt: Some("CHECK_AGENT_PROFILE_PROMPT".into()),
997 skills: vec!["rust-review".into()],
998 ..AgentProfile::default()
999 };
1000
1001 let result = runtime
1002 .agent(profile)
1003 .run(RunRequest::new("review this crate"))
1004 .result()
1005 .await
1006 .unwrap();
1007
1008 assert_eq!(result.final_text, "profile applied");
1009 let system = captured.lock().unwrap();
1010 assert!(system.contains("CHECK_AGENT_PROFILE_PROMPT"), "{system}");
1011 assert!(system.contains("CHECK_AGENT_SKILL_BODY"), "{system}");
1012 }
1013
1014 #[tokio::test]
1015 async fn embedded_read_only_profile_hides_mutating_tools() {
1016 let _guard = RUNTIME_TEST_LOCK.lock().await;
1017 let directory = tempfile::tempdir().unwrap();
1018 let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1019 let runtime = runtime_with_llm(
1020 directory.path(),
1021 Box::new(ToolCapturingLlm {
1022 tools: captured.clone(),
1023 }),
1024 )
1025 .into_embedded_runtime(WORK_CHANNEL, 1)
1026 .unwrap();
1027 let profile = AgentProfile {
1028 name: "reviewer".into(),
1029 tool_policy: microclaw_core::run_protocol::ToolPolicy::ReadOnly,
1030 ..AgentProfile::default()
1031 };
1032
1033 runtime
1034 .agent(profile)
1035 .run(RunRequest::new("inspect this project"))
1036 .result()
1037 .await
1038 .unwrap();
1039
1040 let tools = captured.lock().unwrap();
1041 assert!(tools.iter().any(|name| name == "read_file"), "{tools:?}");
1042 assert!(!tools.iter().any(|name| name == "write_file"), "{tools:?}");
1043 assert!(!tools.iter().any(|name| name == "bash"), "{tools:?}");
1044 }
1045
1046 #[tokio::test]
1047 async fn cancelling_a_headless_session_stops_the_real_agent_loop() {
1048 let _guard = RUNTIME_TEST_LOCK.lock().await;
1049 let directory = tempfile::tempdir().unwrap();
1050 let runtime = Arc::new(runtime_with_llm(directory.path(), Box::new(SlowLlm)));
1051 let (event_tx, mut event_rx) = mpsc::unbounded_channel();
1052 let running_runtime = runtime.clone();
1053 let run = tokio::spawn(async move {
1054 running_runtime
1055 .run(
1056 HeadlessRunRequest::work(
1057 "wait for cancellation".into(),
1058 Some("cancel-test".into()),
1059 "cancel-run-test".into(),
1060 ),
1061 Some(event_tx),
1062 )
1063 .await
1064 });
1065
1066 tokio::time::timeout(std::time::Duration::from_secs(5), async {
1067 loop {
1068 if runtime.cancel_work_session("cancel-test").await.unwrap() > 0 {
1069 break;
1070 }
1071 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1072 }
1073 })
1074 .await
1075 .expect("run should register for cancellation");
1076
1077 let result = tokio::time::timeout(std::time::Duration::from_secs(5), run)
1078 .await
1079 .expect("cancelled run should finish promptly")
1080 .unwrap()
1081 .unwrap();
1082 assert_eq!(result.response, crate::run_control::STOPPED_TEXT);
1083
1084 let mut cancelled = false;
1085 while let Some(envelope) = event_rx.recv().await {
1086 cancelled |= matches!(envelope.event, RuntimeEvent::Cancelled { .. });
1087 }
1088 assert!(cancelled);
1089 }
1090
1091 #[tokio::test]
1092 async fn steering_uses_the_active_agent_turn_queue() {
1093 let _guard = RUNTIME_TEST_LOCK.lock().await;
1094 let directory = tempfile::tempdir().unwrap();
1095 let runtime = runtime_with_fake_llm(directory.path());
1096 let chat_id = runtime.resolve_session_chat_id("steer-test").await.unwrap();
1097 let turn = runtime
1098 .state
1099 .chat_turn_queue
1100 .acquire(HEADLESS_CHANNEL, chat_id)
1101 .await
1102 .unwrap();
1103
1104 assert!(runtime
1105 .steer_session("steer-test", "Focus on the parser first")
1106 .await
1107 .unwrap());
1108 let pending = runtime
1109 .state
1110 .chat_turn_queue
1111 .drain_pending(HEADLESS_CHANNEL, chat_id)
1112 .await;
1113 assert_eq!(pending.len(), 1);
1114 assert_eq!(pending[0].sender_name, "work");
1115 assert_eq!(pending[0].content, "Focus on the parser first");
1116 drop(turn);
1117
1118 assert!(!runtime
1119 .steer_session("steer-test", "This run is already idle")
1120 .await
1121 .unwrap());
1122 }
1123
1124 #[tokio::test]
1125 async fn steering_reaches_the_real_agent_loop_after_a_tool_breakpoint() {
1126 let _guard = RUNTIME_TEST_LOCK.lock().await;
1127 let directory = tempfile::tempdir().unwrap();
1128 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1129 let first_call_started = Arc::new(tokio::sync::Notify::new());
1130 let saw_update = Arc::new(std::sync::atomic::AtomicBool::new(false));
1131 let runtime = Arc::new(runtime_with_llm(
1132 directory.path(),
1133 Box::new(SteeringLlm {
1134 calls: calls.clone(),
1135 first_call_started: first_call_started.clone(),
1136 saw_update: saw_update.clone(),
1137 }),
1138 ));
1139 let running_runtime = runtime.clone();
1140 let run = tokio::spawn(async move {
1141 running_runtime
1142 .run(
1143 HeadlessRunRequest::work(
1144 "Start with the whole task".into(),
1145 Some("live-steer".into()),
1146 "live-steer-run".into(),
1147 ),
1148 None,
1149 )
1150 .await
1151 });
1152
1153 tokio::time::timeout(
1154 std::time::Duration::from_secs(5),
1155 first_call_started.notified(),
1156 )
1157 .await
1158 .expect("first model call should start");
1159 assert!(runtime
1160 .steer_work_session("live-steer", "Focus on the parser first")
1161 .await
1162 .unwrap());
1163 let result = run.await.unwrap().unwrap();
1164
1165 assert_eq!(result.response, "steering applied");
1166 assert!(calls.load(std::sync::atomic::Ordering::SeqCst) >= 2);
1167 assert!(saw_update.load(std::sync::atomic::Ordering::SeqCst));
1168 }
1169}