1use std::sync::Arc;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Instant;
12
13use async_trait::async_trait;
14use futures::future::try_join_all;
15use machi_agent::{
16 Agent, AgentBuilder, AgentDefinition, AgentRegistry, IdentityAssembler, PromptAssembler,
17};
18use machi_llm::LlmSampler;
19use machi_obs::{NoopMetrics, SharedMetrics, record_spawn};
20use machi_state::ChatStateHandle;
21use machi_tools::SharedTool;
22use machi_tools::registry::CapabilityMode;
23use machi_types::{AgentId, ErrorCode, MachiError, Message, Usage};
24use machi_workflow::{WorkflowRunStatus, WorkflowRunStore};
25use serde_json::Value;
26use tokio::sync::{OwnedSemaphorePermit, Semaphore};
27use tokio_util::sync::CancellationToken;
28use tracing::{Instrument, Span, info_span};
29
30use crate::isolation::{InProcessIsolation, IsolationBackend};
31use crate::state::VecConversationState;
32use crate::turn::{TurnInput, TurnOptions, TurnRuntime};
33
34pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 16;
36pub const DEFAULT_MAX_CONCURRENT_CHILDREN: usize = 64;
38
39#[derive(Debug, Clone)]
43pub struct SpawnOpts {
44 pub prompt: String,
46 pub label: Option<String>,
48 pub model: Option<String>,
50 pub capability_mode: CapabilityMode,
52 pub max_steps: Option<usize>,
54 pub cancel: CancellationToken,
56 pub agent_type: Option<String>,
58 pub output_schema: Option<Value>,
60 pub fork_context: bool,
62 pub fork_messages: Option<Vec<Message>>,
64 pub resume_from: Option<String>,
66 pub max_output_tokens: Option<u64>,
68 pub depth: u32,
70}
71
72impl SpawnOpts {
73 #[must_use]
75 pub fn new(prompt: impl Into<String>) -> Self {
76 Self {
77 prompt: prompt.into(),
78 label: None,
79 model: None,
80 capability_mode: CapabilityMode::Full,
81 max_steps: None,
82 cancel: CancellationToken::new(),
83 agent_type: None,
84 output_schema: None,
85 fork_context: false,
86 fork_messages: None,
87 resume_from: None,
88 max_output_tokens: None,
89 depth: 0,
90 }
91 }
92
93 #[must_use]
95 pub fn with_label(mut self, label: impl Into<String>) -> Self {
96 self.label = Some(label.into());
97 self
98 }
99
100 #[must_use]
102 pub const fn with_capability(mut self, mode: CapabilityMode) -> Self {
103 self.capability_mode = mode;
104 self
105 }
106
107 #[must_use]
109 pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
110 self.cancel = cancel;
111 self
112 }
113
114 #[must_use]
116 pub const fn with_max_steps(mut self, max_steps: usize) -> Self {
117 self.max_steps = Some(max_steps);
118 self
119 }
120
121 #[must_use]
123 pub const fn with_depth(mut self, depth: u32) -> Self {
124 self.depth = depth;
125 self
126 }
127
128 #[must_use]
130 pub fn with_agent_type(mut self, agent_type: impl Into<String>) -> Self {
131 self.agent_type = Some(agent_type.into());
132 self
133 }
134
135 #[must_use]
137 pub fn with_output_schema(mut self, schema: Value) -> Self {
138 self.output_schema = Some(schema);
139 self
140 }
141
142 #[must_use]
144 pub const fn with_max_output_tokens(mut self, n: u64) -> Self {
145 self.max_output_tokens = Some(n);
146 self
147 }
148
149 #[must_use]
151 pub const fn with_fork_context(mut self, fork: bool) -> Self {
152 self.fork_context = fork;
153 self
154 }
155
156 #[must_use]
158 pub fn with_fork_messages(mut self, messages: Vec<Message>) -> Self {
159 self.fork_context = true;
160 self.fork_messages = Some(messages);
161 self
162 }
163
164 #[must_use]
166 pub fn with_resume_from(mut self, id: impl Into<String>) -> Self {
167 self.resume_from = Some(id.into());
168 self
169 }
170}
171
172#[derive(Debug, Clone)]
174pub struct AgentRunResult {
175 pub agent_id: AgentId,
177 pub label: Option<String>,
179 pub success: bool,
181 pub output: Value,
183 pub cancelled: bool,
185 pub usage: Usage,
187 pub duration_ms: u64,
189 pub steps: usize,
191}
192
193#[async_trait]
195pub trait SessionHost: Send + Sync {
196 async fn spawn_agent(&self, opts: SpawnOpts) -> Result<AgentRunResult, MachiError>;
198
199 async fn spawn_agents(&self, opts: Vec<SpawnOpts>) -> Result<Vec<AgentRunResult>, MachiError> {
201 try_join_all(opts.into_iter().map(|o| self.spawn_agent(o))).await
202 }
203}
204
205pub struct InProcessHost {
207 sampler: Arc<dyn LlmSampler>,
208 tools: Vec<SharedTool>,
209 base_instructions: String,
210 runtime: TurnRuntime,
211 agent_budget: Option<u64>,
213 spent: AtomicU64,
214 max_spawn_depth: Option<u32>,
216 concurrency: Option<Arc<Semaphore>>,
218 max_concurrent_children: Option<usize>,
219 agent_registry: AgentRegistry,
221 prompt_assembler: Arc<dyn PromptAssembler>,
223 isolation: Arc<dyn IsolationBackend>,
225 metrics: SharedMetrics,
226 parent_handle: Option<ChatStateHandle>,
228 run_store: Option<Arc<dyn WorkflowRunStore>>,
230}
231
232impl std::fmt::Debug for InProcessHost {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 f.debug_struct("InProcessHost")
235 .field("tools", &self.tools.len())
236 .field("base_instructions_len", &self.base_instructions.len())
237 .field("runtime", &self.runtime)
238 .field("agent_budget", &self.agent_budget)
239 .field("spent", &self.spent.load(Ordering::Relaxed))
240 .field("max_spawn_depth", &self.max_spawn_depth)
241 .field("max_concurrent_children", &self.max_concurrent_children)
242 .field("agent_registry", &self.agent_registry.len())
243 .field("isolation", &self.isolation.name())
244 .finish_non_exhaustive()
245 }
246}
247
248impl InProcessHost {
249 #[must_use]
251 pub fn new(sampler: Arc<dyn LlmSampler>, tools: Vec<SharedTool>) -> Self {
252 Self {
253 sampler,
254 tools,
255 base_instructions: "You are a focused sub-agent. Complete the task.".into(),
256 runtime: TurnRuntime::new(),
257 agent_budget: None,
258 spent: AtomicU64::new(0),
259 max_spawn_depth: Some(DEFAULT_MAX_SPAWN_DEPTH),
260 concurrency: Some(Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_CHILDREN))),
261 max_concurrent_children: Some(DEFAULT_MAX_CONCURRENT_CHILDREN),
262 agent_registry: AgentRegistry::with_builtins(),
263 prompt_assembler: Arc::new(IdentityAssembler),
264 isolation: Arc::new(InProcessIsolation),
265 metrics: Arc::new(NoopMetrics),
266 parent_handle: None,
267 run_store: None,
268 }
269 }
270
271 #[must_use]
273 pub const fn with_agent_budget(mut self, budget: u64) -> Self {
274 self.agent_budget = Some(budget);
275 self
276 }
277
278 #[must_use]
280 pub const fn with_max_spawn_depth(mut self, max: Option<u32>) -> Self {
281 self.max_spawn_depth = max;
282 self
283 }
284
285 #[must_use]
287 pub fn with_max_concurrent_children(mut self, max: Option<usize>) -> Self {
288 self.max_concurrent_children = max;
289 self.concurrency = max.map(|n| Arc::new(Semaphore::new(n.max(1))));
290 self
291 }
292
293 #[must_use]
295 pub fn with_agent_registry(mut self, registry: AgentRegistry) -> Self {
296 self.agent_registry = registry;
297 self
298 }
299
300 #[must_use]
302 pub fn with_agent_definitions(
303 mut self,
304 defs: impl IntoIterator<Item = AgentDefinition>,
305 ) -> Self {
306 self.agent_registry = self
307 .agent_registry
308 .merge(&AgentRegistry::from_definitions(defs));
309 self
310 }
311
312 #[must_use]
314 pub fn with_prompt_assembler(mut self, assembler: Arc<dyn PromptAssembler>) -> Self {
315 self.prompt_assembler = assembler;
316 self
317 }
318
319 #[must_use]
321 pub fn with_isolation(mut self, isolation: Arc<dyn IsolationBackend>) -> Self {
322 self.isolation = isolation;
323 self
324 }
325
326 #[must_use]
328 pub fn with_metrics(mut self, metrics: SharedMetrics) -> Self {
329 self.metrics = metrics;
330 self
331 }
332
333 #[must_use]
335 pub fn with_parent_handle(mut self, handle: ChatStateHandle) -> Self {
336 self.parent_handle = Some(handle);
337 self
338 }
339
340 #[must_use]
342 pub fn with_run_store(mut self, store: Arc<dyn WorkflowRunStore>) -> Self {
343 self.run_store = Some(store);
344 self
345 }
346
347 #[must_use]
349 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
350 self.base_instructions = instructions.into();
351 self
352 }
353
354 #[must_use]
356 pub fn agent_registry(&self) -> &AgentRegistry {
357 &self.agent_registry
358 }
359
360 #[must_use]
362 pub fn agents_spent(&self) -> u64 {
363 self.spent.load(Ordering::Relaxed)
364 }
365
366 #[must_use]
368 pub fn agents_remaining(&self) -> Option<u64> {
369 self.agent_budget
370 .map(|b| b.saturating_sub(self.spent.load(Ordering::Relaxed)))
371 }
372
373 #[must_use]
375 pub const fn max_spawn_depth(&self) -> Option<u32> {
376 self.max_spawn_depth
377 }
378
379 #[must_use]
381 pub const fn max_concurrent_children(&self) -> Option<usize> {
382 self.max_concurrent_children
383 }
384
385 fn check_depth(&self, depth: u32) -> Result<(), MachiError> {
386 if let Some(max) = self.max_spawn_depth
387 && depth >= max
388 {
389 return Err(MachiError::new(
390 ErrorCode::HostDepth,
391 format!("spawn depth {depth} exceeds max_spawn_depth {max}"),
392 ));
393 }
394 Ok(())
395 }
396
397 fn check_fork_opts(opts: &SpawnOpts, has_parent_handle: bool) -> Result<(), MachiError> {
398 if opts.fork_context && opts.fork_messages.is_none() && !has_parent_handle {
399 return Err(MachiError::new(
400 ErrorCode::HostUnsupported,
401 "fork_context requires fork_messages or host parent_handle",
402 ));
403 }
404 Ok(())
405 }
406
407 async fn child_state(&self, opts: &SpawnOpts) -> Result<VecConversationState, MachiError> {
408 if !opts.fork_context {
409 return Ok(VecConversationState::new());
410 }
411 if let Some(msgs) = &opts.fork_messages {
412 return Ok(VecConversationState::from_messages(msgs.clone()));
413 }
414 if let Some(handle) = &self.parent_handle {
415 let msgs = handle.messages().await;
416 return Ok(VecConversationState::from_messages(msgs));
417 }
418 Err(MachiError::new(
419 ErrorCode::HostUnsupported,
420 "fork_context requires fork_messages or host parent_handle",
421 ))
422 }
423
424 fn try_resume(&self, opts: &SpawnOpts) -> Result<Option<AgentRunResult>, MachiError> {
426 let Some(id) = opts.resume_from.as_deref() else {
427 return Ok(None);
428 };
429 let Some(store) = &self.run_store else {
430 return Err(MachiError::new(
431 ErrorCode::HostUnsupported,
432 "resume_from requires host run_store (WorkflowRunStore)",
433 ));
434 };
435 let rec = store.get(id).map_err(|e| {
436 MachiError::new(ErrorCode::HostSpawn, format!("workflow run store: {e}"))
437 })?;
438 let Some(rec) = rec else {
439 return Err(MachiError::new(
440 ErrorCode::HostUnsupported,
441 format!("resume_from run_id '{id}' not found in WorkflowRunStore"),
442 ));
443 };
444 match rec.status {
445 WorkflowRunStatus::Completed => {
446 let output = rec.result.clone().unwrap_or(Value::Null);
447 Ok(Some(AgentRunResult {
448 agent_id: AgentId::generate(),
449 label: opts.label.clone().or_else(|| Some(rec.name.clone())),
450 success: true,
451 output,
452 cancelled: false,
453 usage: Usage::zero(),
454 duration_ms: 0,
455 steps: 0,
456 }))
457 }
458 WorkflowRunStatus::Paused | WorkflowRunStatus::BudgetExceeded => Err(MachiError::new(
459 ErrorCode::HostUnsupported,
460 format!(
461 "resume_from '{id}' is {:?}; resume via workflow engine (journal {})",
462 rec.status,
463 rec.journal_path.display()
464 ),
465 )),
466 other => Err(MachiError::new(
467 ErrorCode::HostUnsupported,
468 format!("resume_from '{id}' has non-resumable status {other:?}"),
469 )),
470 }
471 }
472
473 fn try_acquire_concurrency(&self) -> Result<Option<OwnedSemaphorePermit>, MachiError> {
474 let Some(sem) = &self.concurrency else {
475 return Ok(None);
476 };
477 match Arc::clone(sem).try_acquire_owned() {
478 Ok(permit) => Ok(Some(permit)),
479 Err(_) => Err(MachiError::new(
480 ErrorCode::HostConcurrency,
481 format!(
482 "max concurrent children reached ({})",
483 self.max_concurrent_children.unwrap_or(0)
484 ),
485 )),
486 }
487 }
488
489 fn reserve_slot(&self) -> Result<(), MachiError> {
490 let Some(budget) = self.agent_budget else {
491 self.spent.fetch_add(1, Ordering::Relaxed);
492 return Ok(());
493 };
494 self.reserve_against_budget(budget)
495 }
496
497 fn reserve_against_budget(&self, budget: u64) -> Result<(), MachiError> {
498 loop {
499 let spent = self.spent.load(Ordering::Acquire);
500 if spent >= budget {
501 return Err(MachiError::new(
502 ErrorCode::HostBudget,
503 format!("agent budget exhausted: spent {spent}, maximum {budget}"),
504 ));
505 }
506 if self
507 .spent
508 .compare_exchange(spent, spent + 1, Ordering::AcqRel, Ordering::Acquire)
509 .is_ok()
510 {
511 return Ok(());
512 }
513 }
514 }
515
516 fn effective_capability(&self, opts: &SpawnOpts) -> CapabilityMode {
517 let mut mode = opts.capability_mode;
518 if let Some(name) = opts.agent_type.as_deref()
519 && let Some(def) = self.agent_registry.get(name)
520 && let Some(def_cap) = def.capability
521 {
522 mode = mode.intersect(def_cap);
523 }
524 mode
525 }
526
527 fn build_child(&self, opts: &SpawnOpts) -> Result<Agent, MachiError> {
528 let mut builder = if let Some(name) = opts.agent_type.as_deref() {
529 let def = self.agent_registry.require(name)?.clone();
530 let system = self.prompt_assembler.assemble(&def)?;
531 AgentBuilder::from_definition(def)
532 .instructions(system)
533 .tools(self.tools.clone())
534 } else {
535 let name = opts.label.clone().unwrap_or_else(|| "subagent".to_owned());
536 AgentBuilder::named(name)
537 .instructions(self.base_instructions.clone())
538 .tools(self.tools.clone())
539 };
540
541 if let Some(model) = &opts.model {
542 builder = builder.model(model.clone());
543 }
544 if let Some(max_steps) = opts.max_steps {
545 builder = builder.max_steps(max_steps);
546 }
547 if let Some(schema) = opts.output_schema.clone() {
548 builder = builder.output_schema(schema);
549 }
550 builder.build()
551 }
552
553 async fn spawn_one(&self, opts: SpawnOpts) -> Result<AgentRunResult, MachiError> {
554 let agent_id = AgentId::generate();
555 let label = opts.label.clone();
556 let parent = Span::current();
557 let span = info_span!(
558 parent: parent,
559 "machi.spawn",
560 machi.agent_id = %agent_id,
561 machi.agent_label = label.as_deref().unwrap_or(""),
562 machi.agent_type = opts.agent_type.as_deref().unwrap_or(""),
563 machi.capability = ?opts.capability_mode,
564 machi.spawn_depth = opts.depth,
565 );
566
567 async move {
568 if opts.cancel.is_cancelled() {
569 return Err(MachiError::new(
570 ErrorCode::HostCancelled,
571 "spawn cancelled before start",
572 ));
573 }
574 Self::check_fork_opts(&opts, self.parent_handle.is_some())?;
575 self.check_depth(opts.depth)?;
576 let _permit = self.try_acquire_concurrency()?;
578 self.reserve_slot()?;
579 if let Some(resumed) = self.try_resume(&opts)? {
580 record_spawn(self.metrics.as_ref(), "ok");
581 return Ok(resumed);
582 }
583
584 let isolation_env = self.isolation.prepare(&opts).await?;
585 let started = Instant::now();
586 let agent = self.build_child(&opts)?;
587 let mut state = self.child_state(&opts).await?;
588 let capability_mode = self.effective_capability(&opts);
589 let max_steps = opts.max_steps.or_else(|| {
590 opts.agent_type
591 .as_deref()
592 .and_then(|n| self.agent_registry.get(n).map(|d| d.max_steps))
593 });
594 let max_output_tokens = opts.max_output_tokens.and_then(|n| u32::try_from(n).ok());
595 let turn_opts = TurnOptions {
596 max_steps,
597 capability_mode,
598 cancel: opts.cancel.clone(),
599 agent_id: Some(agent_id.clone()),
600 metrics: Arc::clone(&self.metrics),
601 spawn_depth: Some(opts.depth),
602 max_output_tokens,
603 cwd: isolation_env.cwd.clone(),
604 ..TurnOptions::default()
605 };
606 let outcome = match self
607 .runtime
608 .run(
609 &agent,
610 self.sampler.as_ref(),
611 &mut state,
612 TurnInput::Text(opts.prompt),
613 turn_opts,
614 )
615 .await
616 {
617 Ok(o) => o,
618 Err(e) => {
619 let _ = self.isolation.cleanup(&isolation_env).await;
620 record_spawn(self.metrics.as_ref(), "error");
621 return Err(map_turn_error(e));
622 }
623 };
624 if let Err(e) = self.isolation.cleanup(&isolation_env).await {
625 record_spawn(self.metrics.as_ref(), "error");
626 return Err(e);
627 }
628 let status = if outcome.cancelled { "cancelled" } else { "ok" };
629 record_spawn(self.metrics.as_ref(), status);
630
631 let output = outcome
632 .output_json
633 .unwrap_or_else(|| Value::String(outcome.output_text.clone()));
634 Ok(AgentRunResult {
635 agent_id,
636 label,
637 success: !outcome.cancelled,
638 output,
639 cancelled: outcome.cancelled,
640 usage: outcome.usage,
641 duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
642 steps: outcome.steps,
643 })
644 }
645 .instrument(span)
646 .await
647 }
648}
649
650fn map_turn_error(e: MachiError) -> MachiError {
651 if matches!(
652 e.code(),
653 ErrorCode::RuntimeCancelled | ErrorCode::LlmCancelled
654 ) {
655 MachiError::new(ErrorCode::HostCancelled, e.message().to_owned()).with_source(e)
656 } else {
657 MachiError::new(ErrorCode::HostSpawn, e.message().to_owned()).with_source(e)
658 }
659}
660
661#[async_trait]
662impl SessionHost for InProcessHost {
663 async fn spawn_agent(&self, opts: SpawnOpts) -> Result<AgentRunResult, MachiError> {
664 self.spawn_one(opts).await
665 }
666
667 async fn spawn_agents(&self, opts: Vec<SpawnOpts>) -> Result<Vec<AgentRunResult>, MachiError> {
668 try_join_all(opts.into_iter().map(|o| self.spawn_one(o))).await
669 }
670}
671
672#[cfg(test)]
673#[allow(
674 clippy::expect_used,
675 clippy::unwrap_used,
676 clippy::excessive_nesting,
677 reason = "unit tests use expect and nested mock structs"
678)]
679mod tests {
680 use std::sync::Arc;
681
682 use machi_llm::MockSampler;
683 use machi_types::ErrorCode;
684 use serde_json::json;
685
686 use super::*;
687
688 #[tokio::test]
689 async fn concurrent_two_workers() {
690 let sampler = Arc::new(MockSampler::new());
691 sampler.map_user_text("task a", "worker-a-result");
692 sampler.map_user_text("task b", "worker-b-result");
693 let host = InProcessHost::new(sampler, vec![]);
694 let results = host
695 .spawn_agents(vec![
696 SpawnOpts::new("task a").with_label("alpha"),
697 SpawnOpts::new("task b").with_label("beta"),
698 ])
699 .await
700 .expect("spawn");
701 assert_eq!(results.len(), 2);
702 assert_eq!(
703 results.first().and_then(|r| r.label.as_deref()),
704 Some("alpha")
705 );
706 assert_eq!(
707 results.get(1).and_then(|r| r.label.as_deref()),
708 Some("beta")
709 );
710 assert_eq!(
711 results.first().map(|r| &r.output),
712 Some(&Value::String("worker-a-result".into()))
713 );
714 assert_eq!(
715 results.get(1).map(|r| &r.output),
716 Some(&Value::String("worker-b-result".into()))
717 );
718 assert_eq!(host.agents_spent(), 2);
719 }
720
721 #[tokio::test]
722 async fn budget_exhausted() {
723 let sampler = Arc::new(MockSampler::new());
724 sampler.push_text("only-one");
725 let host = InProcessHost::new(sampler, vec![]).with_agent_budget(1);
726 host.spawn_agent(SpawnOpts::new("first"))
727 .await
728 .expect("first");
729 let err = host
730 .spawn_agent(SpawnOpts::new("second"))
731 .await
732 .expect_err("budget");
733 assert_eq!(err.code(), ErrorCode::HostBudget);
734 }
735
736 #[tokio::test]
737 async fn cancel_before_start() {
738 let sampler = Arc::new(MockSampler::new());
739 let host = InProcessHost::new(sampler, vec![]);
740 let cancel = CancellationToken::new();
741 cancel.cancel();
742 let err = host
743 .spawn_agent(SpawnOpts::new("x").with_cancel(cancel))
744 .await
745 .expect_err("cancel");
746 assert_eq!(err.code(), ErrorCode::HostCancelled);
747 }
748
749 #[tokio::test]
750 async fn depth_fail_closed() {
751 let sampler = Arc::new(MockSampler::new());
752 sampler.map_user_text("ok", "done");
753 let host = InProcessHost::new(sampler, vec![]).with_max_spawn_depth(Some(1));
754 host.spawn_agent(SpawnOpts::new("ok").with_depth(0))
755 .await
756 .expect("depth 0");
757 let err = host
758 .spawn_agent(SpawnOpts::new("ok").with_depth(1))
759 .await
760 .expect_err("depth");
761 assert_eq!(err.code(), ErrorCode::HostDepth);
762 }
763
764 #[tokio::test]
765 async fn concurrency_fail_closed() {
766 struct HoldingSampler {
767 inner: MockSampler,
768 release: tokio::sync::Notify,
769 entered: tokio::sync::Notify,
770 }
771
772 #[async_trait]
773 impl LlmSampler for HoldingSampler {
774 async fn sample(
775 &self,
776 request: machi_llm::SampleRequest,
777 ) -> Result<machi_llm::SampleResponse, MachiError> {
778 self.entered.notify_one();
779 self.release.notified().await;
780 self.inner.sample(request).await
781 }
782 }
783
784 let holder = Arc::new(HoldingSampler {
785 inner: MockSampler::new(),
786 release: tokio::sync::Notify::new(),
787 entered: tokio::sync::Notify::new(),
788 });
789 holder.inner.map_user_text("slow", "done");
790 holder.inner.map_user_text("fast", "nope");
791
792 let sampler: Arc<dyn LlmSampler> = holder.clone();
793 let host =
794 Arc::new(InProcessHost::new(sampler, vec![]).with_max_concurrent_children(Some(1)));
795
796 let h1 = Arc::clone(&host);
797 let t1 = tokio::spawn(async move { h1.spawn_agent(SpawnOpts::new("slow")).await });
798 holder.entered.notified().await;
799
800 let err = host
801 .spawn_agent(SpawnOpts::new("fast"))
802 .await
803 .expect_err("concurrency");
804 assert_eq!(err.code(), ErrorCode::HostConcurrency);
805
806 holder.release.notify_one();
807 t1.await.expect("join").expect("first ok");
808 }
809
810 #[tokio::test]
811 async fn fork_context_requires_messages() {
812 let sampler = Arc::new(MockSampler::new());
813 let host = InProcessHost::new(sampler, vec![]);
814 let err = host
815 .spawn_agent(SpawnOpts::new("x").with_fork_context(true))
816 .await
817 .expect_err("fork");
818 assert_eq!(err.code(), ErrorCode::HostUnsupported);
819 }
820
821 #[tokio::test]
822 async fn fork_context_from_parent_handle() {
823 use machi_state::ChatStateHandle;
824 use machi_types::Message;
825
826 let sampler = Arc::new(MockSampler::new());
827 sampler.map_user_text("continue", "handle-fork");
828 let handle = ChatStateHandle::spawn(vec![Message::user("seed"), Message::assistant("a")]);
829 let host = InProcessHost::new(sampler, vec![]).with_parent_handle(handle);
830 let run = host
831 .spawn_agent(SpawnOpts::new("continue").with_fork_context(true))
832 .await
833 .expect("fork");
834 assert_eq!(run.output, Value::String("handle-fork".into()));
835 }
836
837 #[tokio::test]
838 async fn resume_from_completed_run_store() {
839 use std::path::PathBuf;
840 use std::sync::Arc;
841
842 use machi_workflow::{MemoryWorkflowRunStore, WorkflowOutcome, WorkflowRunRecord};
843
844 let sampler = Arc::new(MockSampler::new());
845 let store = Arc::new(MemoryWorkflowRunStore::new());
846 let mut rec = WorkflowRunRecord::new_running("r1", "wf", PathBuf::from("/tmp/j.jsonl"));
847 rec.apply_outcome(&WorkflowOutcome::Completed {
848 result: json!({"ok": true, "v": 1}),
849 });
850 store.put(rec).expect("put");
851 let host = InProcessHost::new(sampler, vec![])
852 .with_agent_budget(2)
853 .with_run_store(store);
854 let run = host
855 .spawn_agent(SpawnOpts::new("unused").with_resume_from("r1"))
856 .await
857 .expect("resume");
858 assert!(run.success);
859 assert_eq!(run.output, json!({"ok": true, "v": 1}));
860 assert_eq!(host.agents_spent(), 1, "resume_from charges budget");
861 }
862
863 #[tokio::test]
864 async fn builtin_explore_spawnable() {
865 let sampler = Arc::new(MockSampler::new());
866 sampler.map_user_text("look", "found");
867 let host = InProcessHost::new(sampler, vec![]);
868 let run = host
869 .spawn_agent(SpawnOpts::new("look").with_agent_type("explore"))
870 .await
871 .expect("explore");
872 assert_eq!(run.output, Value::String("found".into()));
873 }
874
875 #[tokio::test]
876 async fn fork_context_seeds_parent_messages() {
877 use machi_types::Message;
878
879 let sampler = Arc::new(MockSampler::new());
880 sampler.map_user_text("continue", "forked-ok");
882 let host = InProcessHost::new(sampler, vec![]);
883 let parent = vec![
884 Message::system("parent-sys"),
885 Message::user("earlier"),
886 Message::assistant("prior answer"),
887 ];
888 let run = host
889 .spawn_agent(SpawnOpts::new("continue").with_fork_messages(parent))
890 .await
891 .expect("fork spawn");
892 assert_eq!(run.output, Value::String("forked-ok".into()));
893 }
894
895 #[tokio::test]
896 async fn agent_type_not_found() {
897 let sampler = Arc::new(MockSampler::new());
898 let host = InProcessHost::new(sampler, vec![]);
899 let err = host
900 .spawn_agent(SpawnOpts::new("x").with_agent_type("missing"))
901 .await
902 .expect_err("type");
903 assert_eq!(err.code(), ErrorCode::AgentNotFound);
904 }
905
906 #[tokio::test]
907 async fn agent_type_resolves_definition() {
908 let sampler = Arc::new(MockSampler::new());
909 sampler.map_user_text("do work", "from-def");
910 let mut def = AgentDefinition::new("worker");
911 def.description = "w".into();
912 def.instructions = machi_agent::Instructions::Static("Be brief.".into());
913 def.model = "mock".into();
914 def.max_steps = 4;
915 let reg = AgentRegistry::from_definitions([def]);
916 let host = InProcessHost::new(sampler, vec![]).with_agent_registry(reg);
917 let run = host
918 .spawn_agent(SpawnOpts::new("do work").with_agent_type("worker"))
919 .await
920 .expect("spawn");
921 assert_eq!(run.output, Value::String("from-def".into()));
922 }
923
924 #[tokio::test]
925 async fn prompt_assembler_applied_for_agent_type() {
926 use machi_agent::ProjectPromptAssembler;
927
928 let sampler = Arc::new(MockSampler::new());
929 sampler.map_user_text("task", "done");
930 let mut def = AgentDefinition::new("worker");
931 def.description = "w".into();
932 def.instructions = machi_agent::Instructions::Static("Body.".into());
933 def.model = "mock".into();
934 def.max_steps = 4;
935 let asm = Arc::new(ProjectPromptAssembler::with_preamble("PREAMBLE_MARK"));
936 let host = InProcessHost::new(sampler, vec![])
937 .with_agent_definitions([def])
938 .with_prompt_assembler(asm);
939 let run = host
940 .spawn_agent(SpawnOpts::new("task").with_agent_type("worker"))
941 .await
942 .expect("spawn");
943 assert!(run.success);
944 assert_eq!(run.output, Value::String("done".into()));
946 }
947
948 #[tokio::test]
949 async fn output_schema_field_accepted() {
950 let sampler = Arc::new(MockSampler::new());
951 sampler.map_user_text("schema", r#"{"ok":true}"#);
952 let host = InProcessHost::new(sampler, vec![]);
953 let schema = json!({
954 "type": "object",
955 "properties": { "ok": { "type": "boolean" } },
956 "required": ["ok"]
957 });
958 let run = host
959 .spawn_agent(SpawnOpts::new("schema").with_output_schema(schema))
960 .await
961 .expect("spawn");
962 assert!(run.success);
963 }
964
965 #[tokio::test]
966 async fn isolation_prepare_cleanup_on_spawn() {
967 use std::sync::atomic::{AtomicUsize, Ordering as AtOrd};
968
969 use crate::isolation::{IsolationBackend, IsolationEnv};
970
971 struct CountingIsolation {
972 prepares: Arc<AtomicUsize>,
973 cleanups: Arc<AtomicUsize>,
974 }
975
976 #[async_trait]
977 impl IsolationBackend for CountingIsolation {
978 fn name(&self) -> &'static str {
979 "counting"
980 }
981
982 async fn prepare(&self, opts: &SpawnOpts) -> Result<IsolationEnv, MachiError> {
983 self.prepares.fetch_add(1, AtOrd::SeqCst);
984 Ok(IsolationEnv {
985 cwd: None,
986 label: opts.label.clone(),
987 })
988 }
989
990 async fn cleanup(&self, _env: &IsolationEnv) -> Result<(), MachiError> {
991 self.cleanups.fetch_add(1, AtOrd::SeqCst);
992 Ok(())
993 }
994 }
995
996 let sampler = Arc::new(MockSampler::new());
997 sampler.map_user_text("iso", "ok");
998 let prepares = Arc::new(AtomicUsize::new(0));
999 let cleanups = Arc::new(AtomicUsize::new(0));
1000 let host =
1001 InProcessHost::new(sampler, vec![]).with_isolation(Arc::new(CountingIsolation {
1002 prepares: Arc::clone(&prepares),
1003 cleanups: Arc::clone(&cleanups),
1004 }));
1005 host.spawn_agent(SpawnOpts::new("iso").with_label("child"))
1006 .await
1007 .expect("spawn");
1008 assert_eq!(prepares.load(AtOrd::SeqCst), 1, "prepare once");
1009 assert_eq!(cleanups.load(AtOrd::SeqCst), 1, "cleanup once");
1010 }
1011
1012 #[tokio::test]
1013 async fn isolation_prepare_fail_closed() {
1014 use crate::isolation::{IsolationBackend, IsolationEnv, isolation_error};
1015
1016 struct FailPrepare;
1017
1018 #[async_trait]
1019 impl IsolationBackend for FailPrepare {
1020 fn name(&self) -> &'static str {
1021 "fail"
1022 }
1023
1024 async fn prepare(&self, _opts: &SpawnOpts) -> Result<IsolationEnv, MachiError> {
1025 Err(isolation_error(self.name(), "no sandbox available"))
1026 }
1027
1028 async fn cleanup(&self, _env: &IsolationEnv) -> Result<(), MachiError> {
1029 Ok(())
1030 }
1031 }
1032
1033 let sampler = Arc::new(MockSampler::new());
1034 let host = InProcessHost::new(sampler, vec![]).with_isolation(Arc::new(FailPrepare));
1035 let err = host
1036 .spawn_agent(SpawnOpts::new("x"))
1037 .await
1038 .expect_err("iso fail");
1039 assert_eq!(err.code(), ErrorCode::HostIsolation);
1040 assert_eq!(host.agents_spent(), 1, "slot reserved before prepare");
1041 }
1042}