Skip to main content

machi_runtime/
host.rs

1//! Session host for nested agent runs (dynamic multi-agent delegation).
2//!
3//! # Limits (fail-closed)
4//!
5//! - **`agent_budget`** — absolute number of admitted spawns for this host
6//! - **`max_spawn_depth`** — max nesting index (`depth` on [`SpawnOpts`]); `depth >= max` rejects
7//! - **`max_concurrent_children`** — simultaneous in-flight spawns (try-acquire; no queue)
8
9use 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
34/// Default max nesting depth for nested agents (`0..DEFAULT_MAX_SPAWN_DEPTH`).
35pub const DEFAULT_MAX_SPAWN_DEPTH: u32 = 16;
36/// Default max concurrent in-flight nested agents.
37pub const DEFAULT_MAX_CONCURRENT_CHILDREN: usize = 64;
38
39/// Options for spawning a nested agent.
40///
41/// Field parity with workflow [`machi_workflow::AgentOpts`] for Mode A/B isomorphism.
42#[derive(Debug, Clone)]
43pub struct SpawnOpts {
44    /// User prompt for the child.
45    pub prompt: String,
46    /// Optional label for logs / result correlation.
47    pub label: Option<String>,
48    /// Override model.
49    pub model: Option<String>,
50    /// Capability mode for child tools.
51    pub capability_mode: CapabilityMode,
52    /// Max steps for child turn.
53    pub max_steps: Option<usize>,
54    /// Cancel token for this child.
55    pub cancel: CancellationToken,
56    /// Definition name resolved via host agent catalogue.
57    pub agent_type: Option<String>,
58    /// Optional JSON schema for structured child output.
59    pub output_schema: Option<Value>,
60    /// When true, seed the child conversation from [`Self::fork_messages`].
61    pub fork_context: bool,
62    /// Parent messages injected when [`Self::fork_context`] is true.
63    pub fork_messages: Option<Vec<Message>>,
64    /// Replay a completed workflow run id via host [`WorkflowRunStore`] (charges budget).
65    pub resume_from: Option<String>,
66    /// Max output tokens hint for the child sample.
67    pub max_output_tokens: Option<u64>,
68    /// Nesting depth of this spawn (`0` = first level under the host).
69    pub depth: u32,
70}
71
72impl SpawnOpts {
73    /// Prompt-only spawn with a fresh cancel token at depth 0.
74    #[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    /// Set label.
94    #[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    /// Set capability mode.
101    #[must_use]
102    pub const fn with_capability(mut self, mode: CapabilityMode) -> Self {
103        self.capability_mode = mode;
104        self
105    }
106
107    /// Set cancel token (often a child of a parent token).
108    #[must_use]
109    pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
110        self.cancel = cancel;
111        self
112    }
113
114    /// Set max steps.
115    #[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    /// Set nesting depth.
122    #[must_use]
123    pub const fn with_depth(mut self, depth: u32) -> Self {
124        self.depth = depth;
125        self
126    }
127
128    /// Set agent type / definition name.
129    #[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    /// Set structured output schema for the child.
136    #[must_use]
137    pub fn with_output_schema(mut self, schema: Value) -> Self {
138        self.output_schema = Some(schema);
139        self
140    }
141
142    /// Set max output tokens hint.
143    #[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    /// Request parent conversation fork (requires [`Self::with_fork_messages`]).
150    #[must_use]
151    pub const fn with_fork_context(mut self, fork: bool) -> Self {
152        self.fork_context = fork;
153        self
154    }
155
156    /// Seed child state with parent messages and enable fork mode.
157    #[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    /// Replay a completed run from the host [`WorkflowRunStore`] (still charges agent budget).
165    #[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/// Result of a nested agent run.
173#[derive(Debug, Clone)]
174pub struct AgentRunResult {
175    /// Child agent id.
176    pub agent_id: AgentId,
177    /// Optional label echoed from [`SpawnOpts`].
178    pub label: Option<String>,
179    /// Whether the run completed without runtime error.
180    pub success: bool,
181    /// Model text or structured payload.
182    pub output: Value,
183    /// Cancelled flag.
184    pub cancelled: bool,
185    /// Usage.
186    pub usage: Usage,
187    /// Wall duration ms.
188    pub duration_ms: u64,
189    /// Steps.
190    pub steps: usize,
191}
192
193/// Host capable of spawning nested agents.
194#[async_trait]
195pub trait SessionHost: Send + Sync {
196    /// Spawn and run a nested agent to completion.
197    async fn spawn_agent(&self, opts: SpawnOpts) -> Result<AgentRunResult, MachiError>;
198
199    /// Spawn many nested agents concurrently (order of results matches input).
200    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
205/// In-process host: nested [`TurnRuntime`] with shared sampler, tool pool, and limits.
206pub struct InProcessHost {
207    sampler: Arc<dyn LlmSampler>,
208    tools: Vec<SharedTool>,
209    base_instructions: String,
210    runtime: TurnRuntime,
211    /// Absolute cap on nested agent spawns (`None` = unlimited).
212    agent_budget: Option<u64>,
213    spent: AtomicU64,
214    /// Max spawn depth (`None` = unlimited). Depth must satisfy `depth < max`.
215    max_spawn_depth: Option<u32>,
216    /// Concurrent in-flight children (`None` = unlimited).
217    concurrency: Option<Arc<Semaphore>>,
218    max_concurrent_children: Option<usize>,
219    /// Named agent definitions for `agent_type` resolution.
220    agent_registry: AgentRegistry,
221    /// System prompt assembler (project AGENTS.md, etc.).
222    prompt_assembler: Arc<dyn PromptAssembler>,
223    /// Isolation backend for child environments (default in-process).
224    isolation: Arc<dyn IsolationBackend>,
225    metrics: SharedMetrics,
226    /// Parent session handle: seeds `fork_context` when `fork_messages` is unset.
227    parent_handle: Option<ChatStateHandle>,
228    /// Workflow run store for `resume_from` lookups.
229    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    /// Create a host with default depth/concurrency caps and unlimited agent budget.
250    #[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    /// Absolute agent-call budget for this host (every successful admission counts 1).
272    #[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    /// Cap nesting depth (`depth` must be `< max`). `None` disables the limit.
279    #[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    /// Cap concurrent in-flight children. `None` disables the limit.
286    #[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    /// Install a full agent registry for `agent_type` resolution.
294    #[must_use]
295    pub fn with_agent_registry(mut self, registry: AgentRegistry) -> Self {
296        self.agent_registry = registry;
297        self
298    }
299
300    /// Register agent definitions (merged into the host registry).
301    #[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    /// Install a prompt assembler applied when resolving `agent_type` definitions.
313    #[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    /// Install an isolation backend (default [`InProcessIsolation`]).
320    #[must_use]
321    pub fn with_isolation(mut self, isolation: Arc<dyn IsolationBackend>) -> Self {
322        self.isolation = isolation;
323        self
324    }
325
326    /// Metrics sink for spawn/turn accounting.
327    #[must_use]
328    pub fn with_metrics(mut self, metrics: SharedMetrics) -> Self {
329        self.metrics = metrics;
330        self
331    }
332
333    /// Parent conversation handle used when `fork_context` is set without messages.
334    #[must_use]
335    pub fn with_parent_handle(mut self, handle: ChatStateHandle) -> Self {
336        self.parent_handle = Some(handle);
337        self
338    }
339
340    /// Workflow run store enabling `resume_from` on spawn opts.
341    #[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    /// Override child system instructions (used when `agent_type` is unset).
348    #[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    /// Shared agent registry (clone is cheap).
355    #[must_use]
356    pub fn agent_registry(&self) -> &AgentRegistry {
357        &self.agent_registry
358    }
359
360    /// Agents admitted so far (including in-flight after reservation).
361    #[must_use]
362    pub fn agents_spent(&self) -> u64 {
363        self.spent.load(Ordering::Relaxed)
364    }
365
366    /// Remaining budget when capped.
367    #[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    /// Configured max spawn depth.
374    #[must_use]
375    pub const fn max_spawn_depth(&self) -> Option<u32> {
376        self.max_spawn_depth
377    }
378
379    /// Configured max concurrent children.
380    #[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    /// Resolve `resume_from` via [`WorkflowRunStore`] when configured.
425    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            // Budget + concurrency apply to resume_from (no free spawn path).
577            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        // Child user prompt is "continue"; parent context is already in state.
881        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        // Assembler applied at build time; spawn still succeeds with mock.
945        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}