Skip to main content

machi_runtime/
turn.rs

1//! [`TurnRuntime`]: host-agnostic ReAct-style loop with stop gates and compaction.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Instant;
6
7use futures::StreamExt;
8use machi_agent::Agent;
9use machi_compaction::{CompactionStrategy, MaxMessages};
10use machi_llm::{LlmSampler, SampleEvent, SampleRequest, SampleResponse, ToolChoice};
11use machi_obs::{NoopMetrics, SharedMetrics, record_compaction, record_sample};
12use machi_protocol::{PreflightOverflow, check_context_overflow};
13use machi_tools::registry::CapabilityMode;
14use machi_tools::{
15    ApprovalGate, ApprovalPolicy, AutoApprove, DispatchRequest, ToolCallContext, ToolDispatch,
16};
17use machi_types::{AgentId, Deadline, ErrorCode, MachiError, Message, RunId, SessionId, Usage};
18use serde_json::Value;
19use tokio::sync::mpsc;
20use tokio_util::sync::CancellationToken;
21use tracing::{Instrument, info_span};
22
23use crate::gates::{GateChain, GateDecision};
24use crate::lifecycle::{LifecycleFanout, TurnAbortReason, TurnLifecycleContributor};
25use crate::schema::{
26    STRUCTURED_OUTPUT_MAX_RETRIES, compile_schema, schema_retry_reminder,
27    validate_structured_output,
28};
29use crate::state::{ConversationState, estimate_messages_tokens};
30use crate::stationarity::{StationarityAction, StationarityTracker, nudge_message};
31
32/// User-facing turn input.
33#[derive(Debug, Clone)]
34pub enum TurnInput {
35    /// Plain text user message.
36    Text(String),
37    /// Pre-built message.
38    Message(Message),
39}
40
41impl TurnInput {
42    fn into_message(self) -> Message {
43        match self {
44            Self::Text(t) => Message::user(t),
45            Self::Message(m) => m,
46        }
47    }
48}
49
50/// Options for a single turn.
51#[derive(Clone)]
52pub struct TurnOptions {
53    /// Hard step ceiling (defaults to agent `max_steps`).
54    pub max_steps: Option<usize>,
55    /// Tool concurrency.
56    pub max_tool_concurrency: usize,
57    /// Capability mode for tools.
58    pub capability_mode: CapabilityMode,
59    /// Cancel token.
60    pub cancel: CancellationToken,
61    /// Deadline.
62    pub deadline: Option<Deadline>,
63    /// Session id for context.
64    pub session_id: Option<SessionId>,
65    /// Agent id for context.
66    pub agent_id: Option<AgentId>,
67    /// Working directory for path tools.
68    pub cwd: Option<PathBuf>,
69    /// Compaction strategy applied before each sample when present.
70    pub compaction: Option<Arc<dyn CompactionStrategy>>,
71    /// Approval gate for tools.
72    pub approval: Arc<dyn ApprovalGate>,
73    /// When to consult approval.
74    pub approval_policy: ApprovalPolicy,
75    /// Optional override stop-gate chain (default: from agent definition).
76    pub stop_gates: Option<Arc<GateChain>>,
77    /// Metrics sink (default no-op).
78    pub metrics: SharedMetrics,
79    /// Nesting depth when this turn is a host-spawned agent (`None` = top-level session).
80    pub spawn_depth: Option<u32>,
81    /// Optional max output tokens for the sampler.
82    pub max_output_tokens: Option<u32>,
83    /// Prefer [`LlmSampler::sample_stream`] and aggregate into a full response.
84    pub use_stream: bool,
85    /// Lifecycle contributors (W3.5).
86    pub contributors: Arc<dyn TurnLifecycleContributor>,
87    /// Optional mid-turn user interjections drained before each sample (W3.6).
88    pub interject_rx: Option<Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<Message>>>>,
89    /// Context window size for preflight overflow (tokens). `None` disables check.
90    pub context_window_tokens: Option<u32>,
91    /// Soft threshold ratio of context window (default 0.9).
92    pub context_overflow_ratio: f32,
93    /// When true, overflow after compaction is a hard error; else continue.
94    pub fail_on_context_overflow: bool,
95}
96
97impl std::fmt::Debug for TurnOptions {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("TurnOptions")
100            .field("max_steps", &self.max_steps)
101            .field("max_tool_concurrency", &self.max_tool_concurrency)
102            .field("capability_mode", &self.capability_mode)
103            .field("cwd", &self.cwd)
104            .field("has_compaction", &self.compaction.is_some())
105            .field("approval_policy", &self.approval_policy)
106            .field("spawn_depth", &self.spawn_depth)
107            .field("max_output_tokens", &self.max_output_tokens)
108            .field("use_stream", &self.use_stream)
109            .field("context_window_tokens", &self.context_window_tokens)
110            .field("context_overflow_ratio", &self.context_overflow_ratio)
111            .field("fail_on_context_overflow", &self.fail_on_context_overflow)
112            .finish_non_exhaustive()
113    }
114}
115
116impl Default for TurnOptions {
117    fn default() -> Self {
118        Self {
119            max_steps: None,
120            max_tool_concurrency: 32,
121            capability_mode: CapabilityMode::Full,
122            cancel: CancellationToken::new(),
123            deadline: None,
124            session_id: None,
125            agent_id: None,
126            cwd: None,
127            compaction: None,
128            approval: Arc::new(AutoApprove),
129            approval_policy: ApprovalPolicy::Destructive,
130            stop_gates: None,
131            metrics: Arc::new(NoopMetrics),
132            spawn_depth: None,
133            max_output_tokens: None,
134            use_stream: false,
135            contributors: Arc::new(LifecycleFanout::new()),
136            interject_rx: None,
137            context_window_tokens: None,
138            context_overflow_ratio: 0.9,
139            fail_on_context_overflow: true,
140        }
141    }
142}
143
144impl TurnOptions {
145    /// Cap conversation length via [`MaxMessages`] strategy.
146    ///
147    /// # Errors
148    ///
149    /// Returns error when `max == 0`.
150    pub fn with_max_messages(mut self, max: usize) -> Result<Self, MachiError> {
151        self.compaction = Some(Arc::new(MaxMessages::new(max)?));
152        Ok(self)
153    }
154
155    /// Install a compaction strategy.
156    #[must_use]
157    pub fn with_compaction(mut self, strategy: Arc<dyn CompactionStrategy>) -> Self {
158        self.compaction = Some(strategy);
159        self
160    }
161
162    /// Set cwd for tools.
163    #[must_use]
164    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
165        self.cwd = Some(cwd.into());
166        self
167    }
168
169    /// Set capability mode.
170    #[must_use]
171    pub const fn with_capability(mut self, mode: CapabilityMode) -> Self {
172        self.capability_mode = mode;
173        self
174    }
175
176    /// Set max steps.
177    #[must_use]
178    pub const fn with_max_steps(mut self, max_steps: usize) -> Self {
179        self.max_steps = Some(max_steps);
180        self
181    }
182
183    /// Set cancel token.
184    #[must_use]
185    pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
186        self.cancel = cancel;
187        self
188    }
189
190    /// Set approval gate.
191    #[must_use]
192    pub fn with_approval(mut self, gate: Arc<dyn ApprovalGate>) -> Self {
193        self.approval = gate;
194        self
195    }
196
197    /// Set approval policy.
198    #[must_use]
199    pub const fn with_approval_policy(mut self, policy: ApprovalPolicy) -> Self {
200        self.approval_policy = policy;
201        self
202    }
203
204    /// Set metrics sink.
205    #[must_use]
206    pub fn with_metrics(mut self, metrics: SharedMetrics) -> Self {
207        self.metrics = metrics;
208        self
209    }
210
211    /// Set absolute deadline for the turn (sample + tools).
212    #[must_use]
213    pub const fn with_deadline(mut self, deadline: Deadline) -> Self {
214        self.deadline = Some(deadline);
215        self
216    }
217
218    /// Prefer streaming sample aggregation for this turn.
219    #[must_use]
220    pub const fn with_stream(mut self, use_stream: bool) -> Self {
221        self.use_stream = use_stream;
222        self
223    }
224
225    /// Override stop-gate chain.
226    #[must_use]
227    pub fn with_stop_gates(mut self, gates: Arc<GateChain>) -> Self {
228        self.stop_gates = Some(gates);
229        self
230    }
231
232    /// Install lifecycle contributors (W3.5).
233    #[must_use]
234    pub fn with_contributors(mut self, contributors: Arc<dyn TurnLifecycleContributor>) -> Self {
235        self.contributors = contributors;
236        self
237    }
238
239    /// Mid-turn interjection channel drained before each sample (W3.6).
240    #[must_use]
241    pub fn with_interject_rx(
242        mut self,
243        rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<Message>>>,
244    ) -> Self {
245        self.interject_rx = Some(rx);
246        self
247    }
248
249    /// Enable preflight context overflow checks (W3.2).
250    #[must_use]
251    pub const fn with_context_window(mut self, tokens: u32) -> Self {
252        self.context_window_tokens = Some(tokens);
253        self
254    }
255}
256
257/// Successful or failed turn result.
258#[derive(Debug, Clone)]
259pub struct TurnOutcome {
260    /// Run id.
261    pub run_id: RunId,
262    /// Final assistant text when completed normally.
263    pub output_text: String,
264    /// Optional structured JSON when schema mode produced parseable content.
265    pub output_json: Option<Value>,
266    /// Accumulated usage.
267    pub usage: Usage,
268    /// Steps consumed.
269    pub steps: usize,
270    /// Whether cancelled.
271    pub cancelled: bool,
272}
273
274/// Stateless turn engine.
275#[derive(Debug, Default, Clone, Copy)]
276pub struct TurnRuntime;
277
278impl TurnRuntime {
279    /// Create a runtime.
280    #[must_use]
281    pub const fn new() -> Self {
282        Self
283    }
284
285    /// Run one turn to completion.
286    ///
287    /// # Errors
288    ///
289    /// Returns typed runtime/LLM/tool failures.
290    pub async fn run(
291        &self,
292        agent: &Agent,
293        sampler: &dyn LlmSampler,
294        state: &mut dyn ConversationState,
295        input: TurnInput,
296        options: TurnOptions,
297    ) -> Result<TurnOutcome, MachiError> {
298        let run_id = RunId::generate();
299        let span = info_span!(
300            "machi.turn",
301            machi.run_id = %run_id,
302            machi.agent_name = agent.name(),
303            machi.model = agent.model(),
304        );
305
306        async move {
307            self.run_inner(agent, sampler, state, input, options, run_id)
308                .await
309        }
310        .instrument(span)
311        .await
312    }
313
314    #[allow(
315        clippy::too_many_lines,
316        clippy::excessive_nesting,
317        reason = "turn loop owns cancel/preflight/stationarity/dispatch/lifecycle"
318    )]
319    async fn run_inner(
320        &self,
321        agent: &Agent,
322        sampler: &dyn LlmSampler,
323        state: &mut dyn ConversationState,
324        input: TurnInput,
325        options: TurnOptions,
326        run_id: RunId,
327    ) -> Result<TurnOutcome, MachiError> {
328        let agent_max = agent.max_steps();
329        let max_steps = options.max_steps.unwrap_or(agent_max);
330        if max_steps == 0 {
331            return Err(MachiError::new(
332                ErrorCode::RuntimeMaxSteps,
333                "max_steps must be >= 1",
334            ));
335        }
336
337        if state.messages().is_empty() && !agent.system_prompt().is_empty() {
338            state.append(Message::system(agent.system_prompt()));
339        }
340        state.append(input.into_message());
341
342        let mut usage = Usage::zero();
343        let mut steps = 0usize;
344        let mut completion_retries_used = 0u32;
345        let mut schema_retries_used = 0u32;
346        let schema_validator = match agent.definition().output_schema.as_ref() {
347            Some(schema) => Some(compile_schema(schema)?),
348            None => None,
349        };
350        let stop_gates = options
351            .stop_gates
352            .clone()
353            .unwrap_or_else(|| Arc::new(GateChain::from_agent(agent)));
354        let dispatch = ToolDispatch::default()
355            .with_max_concurrency(options.max_tool_concurrency)
356            .with_capability(options.capability_mode)
357            .with_approval(Arc::clone(&options.approval))
358            .with_approval_policy(options.approval_policy)
359            .with_metrics(Arc::clone(&options.metrics));
360
361        options.contributors.on_turn_start(&run_id);
362        let mut stationarity = StationarityTracker::new();
363
364        loop {
365            if options.cancel.is_cancelled() {
366                options
367                    .contributors
368                    .on_turn_abort(&run_id, &TurnAbortReason::Cancelled);
369                return Ok(empty_cancelled(run_id, usage, steps));
370            }
371            if deadline_expired(&options) {
372                let err = MachiError::new(ErrorCode::RuntimeDeadline, "turn deadline expired");
373                options
374                    .contributors
375                    .on_turn_abort(&run_id, &TurnAbortReason::from_error(&err));
376                return Err(err);
377            }
378            if steps >= max_steps {
379                let err = MachiError::new(
380                    ErrorCode::RuntimeMaxSteps,
381                    format!("exceeded max_steps ({max_steps})"),
382                );
383                options
384                    .contributors
385                    .on_turn_abort(&run_id, &TurnAbortReason::from_error(&err));
386                return Err(err);
387            }
388            steps = steps.saturating_add(1);
389            let step_u32 = u32::try_from(steps).unwrap_or(u32::MAX);
390
391            drain_interjections(state, &options).await;
392
393            maybe_compact(
394                state,
395                options.compaction.as_deref(),
396                options.metrics.as_ref(),
397                false,
398            )?;
399
400            if let Err(err) =
401                preflight_with_optional_force_compact(state, &options, options.metrics.as_ref())
402            {
403                options.contributors.on_turn_error(&run_id, &err);
404                return Err(err);
405            }
406
407            let tools = agent.tools().definitions(options.capability_mode);
408            let request = SampleRequest {
409                model: agent.model().to_owned(),
410                messages: state.messages().to_vec(),
411                tools,
412                tool_choice: ToolChoice::Auto,
413                response_format: agent.definition().output_schema.clone(),
414                max_output_tokens: options.max_output_tokens,
415                temperature: None,
416                cancel: options.cancel.clone(),
417                deadline: options.deadline,
418            };
419
420            let sample_span = info_span!("machi.sample", machi.step = step_u32);
421            let sample_started = Instant::now();
422            let response = match sample_once(sampler, request, &options)
423                .instrument(sample_span)
424                .await
425            {
426                Ok(r) => r,
427                Err(e) => {
428                    return finish_sample_error(e, &options, run_id, usage, steps);
429                }
430            };
431            let sample_ms = sample_started.elapsed().as_secs_f64() * 1000.0;
432            record_sample(
433                options.metrics.as_ref(),
434                sample_ms,
435                u64::from(response.usage.input_tokens),
436                u64::from(response.usage.output_tokens),
437            );
438            usage += response.usage;
439
440            let message = response.message;
441            if message.tool_calls.is_empty() {
442                stationarity.reset();
443                let mut final_ctx = FinalCtx {
444                    agent,
445                    state,
446                    message: &message,
447                    schema_validator: schema_validator.as_ref(),
448                    stop_gates: stop_gates.as_ref(),
449                    completion_retries_used: &mut completion_retries_used,
450                    schema_retries_used: &mut schema_retries_used,
451                    run_id: run_id.clone(),
452                    usage,
453                    steps,
454                };
455                match handle_final_assistant(&mut final_ctx) {
456                    Ok(FinalStep::Done(outcome)) => {
457                        options.contributors.on_turn_done(&run_id, outcome.steps);
458                        return Ok(outcome);
459                    }
460                    Ok(FinalStep::Continue) => continue,
461                    Err(e) => {
462                        options.contributors.on_turn_error(&run_id, &e);
463                        return Err(e);
464                    }
465                }
466            }
467
468            match stationarity.observe_tool_batch(&message.tool_calls) {
469                StationarityAction::Ok => {}
470                StationarityAction::Nudge { reminder } => {
471                    state.append(nudge_message(reminder));
472                }
473                StationarityAction::HardStop { error } => {
474                    options
475                        .contributors
476                        .on_turn_abort(&run_id, &TurnAbortReason::from_error(&error));
477                    return Err(error);
478                }
479            }
480
481            dispatch_tools(agent, state, &options, &dispatch, message, step_u32).await;
482        }
483    }
484}
485
486/// Preflight overflow: if over limit, force one compaction pass then re-check.
487fn preflight_with_optional_force_compact(
488    state: &mut dyn ConversationState,
489    options: &TurnOptions,
490    metrics: &dyn machi_obs::MetricsSink,
491) -> Result<(), MachiError> {
492    let Some(window) = options.context_window_tokens else {
493        return Ok(());
494    };
495    let estimated = estimate_messages_tokens(state.messages());
496    match check_context_overflow(estimated, window, options.context_overflow_ratio) {
497        PreflightOverflow::Ok { .. } => Ok(()),
498        PreflightOverflow::Overflow { .. } => {
499            // ROADMAP 3.2: overflow → compact or typed error.
500            maybe_compact(state, options.compaction.as_deref(), metrics, true)?;
501            let estimated2 = estimate_messages_tokens(state.messages());
502            match check_context_overflow(estimated2, window, options.context_overflow_ratio) {
503                PreflightOverflow::Ok { .. } => Ok(()),
504                PreflightOverflow::Overflow {
505                    estimated,
506                    limit,
507                    window,
508                } if options.fail_on_context_overflow => Err(MachiError::new(
509                    ErrorCode::CompactionOverflow,
510                    format!(
511                        "context overflow after compaction: estimated {estimated} tokens \
512                         exceeds limit {limit} (window {window})"
513                    ),
514                )),
515                PreflightOverflow::Overflow { .. } => Ok(()),
516            }
517        }
518    }
519}
520
521fn finish_sample_error(
522    e: MachiError,
523    options: &TurnOptions,
524    run_id: RunId,
525    usage: Usage,
526    steps: usize,
527) -> Result<TurnOutcome, MachiError> {
528    let mapped = map_sample_error(e, options, run_id.clone(), usage, steps);
529    match &mapped {
530        Ok(outcome) if outcome.cancelled => {
531            options
532                .contributors
533                .on_turn_abort(&run_id, &TurnAbortReason::Cancelled);
534        }
535        Err(err) if err.code() == ErrorCode::RuntimeDeadline => {
536            options
537                .contributors
538                .on_turn_abort(&run_id, &TurnAbortReason::Deadline);
539        }
540        Err(err) => {
541            options.contributors.on_turn_error(&run_id, err);
542        }
543        Ok(_) => {}
544    }
545    mapped
546}
547
548async fn drain_interjections(state: &mut dyn ConversationState, options: &TurnOptions) {
549    let Some(rx) = &options.interject_rx else {
550        return;
551    };
552    let mut guard = rx.lock().await;
553    while let Ok(msg) = guard.try_recv() {
554        state.append(msg);
555    }
556}
557
558/// Estimate tokens for a conversation (re-export of shared estimator).
559#[must_use]
560pub fn estimate_conversation_tokens(messages: &[Message]) -> u32 {
561    estimate_messages_tokens(messages)
562}
563
564enum FinalStep {
565    Done(TurnOutcome),
566    Continue,
567}
568
569struct FinalCtx<'a> {
570    agent: &'a Agent,
571    state: &'a mut dyn ConversationState,
572    message: &'a Message,
573    schema_validator: Option<&'a jsonschema::Validator>,
574    stop_gates: &'a GateChain,
575    completion_retries_used: &'a mut u32,
576    schema_retries_used: &'a mut u32,
577    run_id: RunId,
578    usage: Usage,
579    steps: usize,
580}
581
582fn handle_final_assistant(ctx: &mut FinalCtx<'_>) -> Result<FinalStep, MachiError> {
583    ctx.state.append(ctx.message.clone());
584
585    if let Some(validator) = ctx.schema_validator {
586        match validate_structured_output(validator, &ctx.message.text()) {
587            Ok(value) => return apply_stop_gates(ctx, Some(value)),
588            Err(err) => {
589                if *ctx.schema_retries_used >= STRUCTURED_OUTPUT_MAX_RETRIES {
590                    return Err(MachiError::new(
591                        ErrorCode::RuntimeStructuredOutput,
592                        format!(
593                            "structured output invalid after {STRUCTURED_OUTPUT_MAX_RETRIES} retries: {err}"
594                        ),
595                    ));
596                }
597                *ctx.schema_retries_used = ctx.schema_retries_used.saturating_add(1);
598                ctx.state.append(Message::user(schema_retry_reminder(&err)));
599                return Ok(FinalStep::Continue);
600            }
601        }
602    }
603
604    apply_stop_gates(ctx, None)
605}
606
607fn apply_stop_gates(
608    ctx: &mut FinalCtx<'_>,
609    output_json: Option<Value>,
610) -> Result<FinalStep, MachiError> {
611    match ctx
612        .stop_gates
613        .evaluate(ctx.agent, ctx.state, *ctx.completion_retries_used)
614    {
615        GateDecision::Complete => Ok(FinalStep::Done(TurnOutcome {
616            run_id: ctx.run_id.clone(),
617            output_text: ctx.message.text(),
618            output_json: output_json.or_else(|| {
619                ctx.agent
620                    .definition()
621                    .output_schema
622                    .as_ref()
623                    .and_then(|_| serde_json::from_str(&ctx.message.text()).ok())
624            }),
625            usage: ctx.usage,
626            steps: ctx.steps,
627            cancelled: false,
628        })),
629        GateDecision::Continue { reminder } => {
630            *ctx.completion_retries_used = ctx.completion_retries_used.saturating_add(1);
631            ctx.state.append(Message::user(reminder));
632            Ok(FinalStep::Continue)
633        }
634    }
635}
636
637async fn dispatch_tools(
638    agent: &Agent,
639    state: &mut dyn ConversationState,
640    options: &TurnOptions,
641    dispatch: &ToolDispatch,
642    message: Message,
643    step_u32: u32,
644) {
645    state.append(message.clone());
646    let mut extras = std::collections::HashMap::new();
647    if let Some(depth) = options.spawn_depth {
648        extras.insert(machi_tools::EXTRA_SPAWN_DEPTH.to_owned(), depth.to_string());
649    }
650    let ctx = ToolCallContext {
651        cancel: options.cancel.clone(),
652        deadline: options.deadline,
653        cwd: options.cwd.clone(),
654        session_id: options.session_id.clone(),
655        agent_id: options.agent_id.clone(),
656        extras: Arc::new(extras),
657    };
658    let requests: Vec<DispatchRequest> = message
659        .tool_calls
660        .into_iter()
661        .map(|call| DispatchRequest { call })
662        .collect();
663    let batch_span = info_span!("machi.tool.batch", machi.step = step_u32);
664    let outcomes = dispatch
665        .execute_batch(agent.tools(), ctx, requests)
666        .instrument(batch_span)
667        .await;
668    for out in outcomes {
669        let content = match out.result {
670            Ok(r) => r.content,
671            Err(e) => format!("error: {e}"),
672        };
673        state.append(Message::tool_result(out.id, out.name, content));
674    }
675}
676
677fn maybe_compact(
678    state: &mut dyn ConversationState,
679    strategy: Option<&dyn CompactionStrategy>,
680    metrics: &dyn machi_obs::MetricsSink,
681    force: bool,
682) -> Result<(), MachiError> {
683    let Some(strategy) = strategy else {
684        return Ok(());
685    };
686    let msgs = state.messages();
687    let tokens = state.token_estimate();
688    if !force && !strategy.should_compact(msgs, tokens) {
689        return Ok(());
690    }
691    let name = strategy.name();
692    match strategy.compact(msgs.to_vec()) {
693        Ok(outcome) => {
694            if outcome.changed {
695                state.replace(outcome.messages);
696                record_compaction(metrics, name, "ok");
697            }
698            Ok(())
699        }
700        Err(e) => {
701            record_compaction(metrics, name, "error");
702            Err(e)
703        }
704    }
705}
706
707fn deadline_expired(options: &TurnOptions) -> bool {
708    options.deadline.is_some_and(|d| d.is_expired())
709}
710
711async fn sample_once(
712    sampler: &dyn LlmSampler,
713    request: SampleRequest,
714    options: &TurnOptions,
715) -> Result<SampleResponse, MachiError> {
716    if options.use_stream {
717        let stream = sampler.sample_stream(request).await?;
718        collect_sample_stream(stream).await
719    } else {
720        sampler.sample(request).await
721    }
722}
723
724async fn collect_sample_stream(
725    mut stream: machi_llm::SampleStream,
726) -> Result<SampleResponse, MachiError> {
727    let mut message: Option<Message> = None;
728    let mut usage = Usage::zero();
729    let mut stop_reason = None;
730    let mut text_buf = String::new();
731
732    while let Some(ev) = stream.next().await {
733        match ev {
734            SampleEvent::TextDelta { text } => text_buf.push_str(&text),
735            SampleEvent::ToolCalls { message: m } => message = Some(m),
736            SampleEvent::Usage(u) => usage += u,
737            SampleEvent::Completed {
738                message: m,
739                stop_reason: reason,
740            } => {
741                message = Some(m);
742                stop_reason = reason;
743            }
744            SampleEvent::Failed { message: msg } => {
745                return Err(MachiError::new(ErrorCode::LlmInvalidResponse, msg));
746            }
747            _ => {}
748        }
749    }
750
751    let message = match message {
752        Some(m) => m,
753        None if !text_buf.is_empty() => Message::assistant(text_buf),
754        None => {
755            return Err(MachiError::new(
756                ErrorCode::LlmInvalidResponse,
757                "sample stream ended without Completed",
758            ));
759        }
760    };
761
762    Ok(SampleResponse {
763        message,
764        usage,
765        stop_reason,
766    })
767}
768
769fn map_sample_error(
770    e: MachiError,
771    options: &TurnOptions,
772    run_id: RunId,
773    usage: Usage,
774    steps: usize,
775) -> Result<TurnOutcome, MachiError> {
776    if deadline_expired(options) {
777        return Err(
778            MachiError::new(ErrorCode::RuntimeDeadline, e.message().to_owned()).with_source(e),
779        );
780    }
781    if e.code() == ErrorCode::LlmCancelled || options.cancel.is_cancelled() {
782        return Ok(TurnOutcome {
783            run_id,
784            output_text: String::new(),
785            output_json: None,
786            usage,
787            steps,
788            cancelled: true,
789        });
790    }
791    Err(e)
792}
793
794fn empty_cancelled(run_id: RunId, usage: Usage, steps: usize) -> TurnOutcome {
795    TurnOutcome {
796        run_id,
797        output_text: String::new(),
798        output_json: None,
799        usage,
800        steps,
801        cancelled: true,
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use std::sync::Arc;
808
809    use async_trait::async_trait;
810    use machi_agent::AgentBuilder;
811    use machi_llm::MockSampler;
812    use machi_tools::{AlwaysDeny, DynTool, ToolMetadata, ToolResult};
813    use machi_types::{Message, ToolCall, ToolCallId};
814    use serde_json::{Value, json};
815
816    use super::*;
817    use crate::state::VecConversationState;
818
819    struct EchoTool;
820
821    #[async_trait]
822    impl DynTool for EchoTool {
823        fn name(&self) -> &'static str {
824            "echo"
825        }
826        fn description(&self) -> &'static str {
827            "echo"
828        }
829        fn parameters(&self) -> Value {
830            json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
831        }
832        fn metadata(&self) -> ToolMetadata {
833            ToolMetadata::read_only()
834        }
835        async fn call(
836            &self,
837            _ctx: ToolCallContext,
838            arguments: Value,
839        ) -> Result<ToolResult, MachiError> {
840            let text = arguments
841                .get("text")
842                .and_then(Value::as_str)
843                .unwrap_or_default();
844            Ok(ToolResult::text(text))
845        }
846    }
847
848    struct WriteStub;
849
850    #[async_trait]
851    impl DynTool for WriteStub {
852        fn name(&self) -> &'static str {
853            "write_stub"
854        }
855        fn description(&self) -> &'static str {
856            "write"
857        }
858        fn parameters(&self) -> Value {
859            json!({"type":"object","properties":{}})
860        }
861        fn metadata(&self) -> ToolMetadata {
862            ToolMetadata::exclusive_write()
863        }
864        async fn call(
865            &self,
866            _ctx: ToolCallContext,
867            _arguments: Value,
868        ) -> Result<ToolResult, MachiError> {
869            Ok(ToolResult::text("wrote"))
870        }
871    }
872
873    struct SubmitTool;
874
875    #[async_trait]
876    impl DynTool for SubmitTool {
877        fn name(&self) -> &'static str {
878            "submit"
879        }
880        fn description(&self) -> &'static str {
881            "submit"
882        }
883        fn parameters(&self) -> Value {
884            json!({"type":"object","properties":{}})
885        }
886        fn metadata(&self) -> ToolMetadata {
887            ToolMetadata::read_only()
888        }
889        async fn call(
890            &self,
891            _ctx: ToolCallContext,
892            _arguments: Value,
893        ) -> Result<ToolResult, MachiError> {
894            Ok(ToolResult::text("submitted"))
895        }
896    }
897
898    #[tokio::test]
899    async fn tool_then_final() {
900        let sampler = Arc::new(MockSampler::new());
901        let id = ToolCallId::new("c1").expect("id");
902        sampler.push_tools(Message::assistant_tools(vec![ToolCall {
903            id,
904            name: "echo".into(),
905            arguments: json!({"text":"pong"}),
906        }]));
907        sampler.push_text("done");
908
909        let agent = AgentBuilder::named("a")
910            .model("mock")
911            .tools(vec![Arc::new(EchoTool)])
912            .build()
913            .expect("agent");
914        let mut state = VecConversationState::new();
915        let out = TurnRuntime::new()
916            .run(
917                &agent,
918                sampler.as_ref(),
919                &mut state,
920                TurnInput::Text("ping".into()),
921                TurnOptions::default(),
922            )
923            .await
924            .expect("turn");
925        assert_eq!(out.output_text, "done");
926        assert!(out.steps >= 2);
927    }
928
929    #[tokio::test]
930    async fn max_steps() {
931        let sampler = Arc::new(MockSampler::new());
932        let id = ToolCallId::new("c1").expect("id");
933        sampler.push_tools(Message::assistant_tools(vec![ToolCall {
934            id: id.clone(),
935            name: "echo".into(),
936            arguments: json!({"text":"x"}),
937        }]));
938        sampler.push_tools(Message::assistant_tools(vec![ToolCall {
939            id,
940            name: "echo".into(),
941            arguments: json!({"text":"y"}),
942        }]));
943        let agent = AgentBuilder::named("a")
944            .model("mock")
945            .tools(vec![Arc::new(EchoTool)])
946            .max_steps(1)
947            .build()
948            .expect("agent");
949        let mut state = VecConversationState::new();
950        let err = TurnRuntime::new()
951            .run(
952                &agent,
953                sampler.as_ref(),
954                &mut state,
955                TurnInput::Text("ping".into()),
956                TurnOptions::default(),
957            )
958            .await
959            .expect_err("max steps");
960        assert_eq!(err.code(), ErrorCode::RuntimeMaxSteps);
961    }
962
963    #[tokio::test]
964    async fn approval_denies_write_tool() {
965        let sampler = Arc::new(MockSampler::new());
966        let id = ToolCallId::new("w1").expect("id");
967        sampler.push_tools(Message::assistant_tools(vec![ToolCall {
968            id,
969            name: "write_stub".into(),
970            arguments: json!({}),
971        }]));
972        sampler.push_text("after-deny");
973        let agent = AgentBuilder::named("a")
974            .model("mock")
975            .tools(vec![Arc::new(WriteStub)])
976            .build()
977            .expect("agent");
978        let mut state = VecConversationState::new();
979        let out = TurnRuntime::new()
980            .run(
981                &agent,
982                sampler.as_ref(),
983                &mut state,
984                TurnInput::Text("write".into()),
985                TurnOptions::default().with_approval(Arc::new(AlwaysDeny)),
986            )
987            .await
988            .expect("turn continues after tool error");
989        assert_eq!(out.output_text, "after-deny");
990        let texts: String = state.messages().iter().map(Message::text).collect();
991        assert!(
992            texts.contains("approval denied") || texts.contains("error:"),
993            "{texts}"
994        );
995    }
996
997    #[tokio::test]
998    async fn max_messages_compaction() {
999        let sampler = Arc::new(MockSampler::new());
1000        sampler.push_text("ok");
1001        let agent = AgentBuilder::named("a")
1002            .model("mock")
1003            .build()
1004            .expect("agent");
1005        let mut state = VecConversationState::from_messages(vec![
1006            Message::system("sys"),
1007            Message::user("1"),
1008            Message::user("2"),
1009            Message::user("3"),
1010            Message::user("4"),
1011        ]);
1012        let opts = TurnOptions::default().with_max_messages(3).expect("max");
1013        let _ = TurnRuntime::new()
1014            .run(
1015                &agent,
1016                sampler.as_ref(),
1017                &mut state,
1018                TurnInput::Text("new".into()),
1019                opts,
1020            )
1021            .await
1022            .expect("turn");
1023        // system + compacted tail + new user + assistant at least
1024        assert!(state.messages().len() <= 6);
1025        assert_eq!(
1026            state.messages().first().map(Message::text).as_deref(),
1027            Some("sys")
1028        );
1029    }
1030
1031    #[tokio::test]
1032    async fn cancel_before_sample() {
1033        let sampler = Arc::new(MockSampler::new());
1034        sampler.push_text("should-not-run");
1035        let agent = AgentBuilder::named("a")
1036            .model("mock")
1037            .build()
1038            .expect("agent");
1039        let mut state = VecConversationState::new();
1040        let cancel = CancellationToken::new();
1041        cancel.cancel();
1042        let out = TurnRuntime::new()
1043            .run(
1044                &agent,
1045                sampler.as_ref(),
1046                &mut state,
1047                TurnInput::Text("hi".into()),
1048                TurnOptions::default().with_cancel(cancel),
1049            )
1050            .await
1051            .expect("cancelled ok");
1052        assert!(out.cancelled);
1053        assert!(out.output_text.is_empty());
1054    }
1055
1056    #[tokio::test]
1057    async fn deadline_before_sample() {
1058        use std::time::Duration;
1059
1060        let sampler = Arc::new(MockSampler::new());
1061        sampler.push_text("late");
1062        let agent = AgentBuilder::named("a")
1063            .model("mock")
1064            .build()
1065            .expect("agent");
1066        let mut state = VecConversationState::new();
1067        let err = TurnRuntime::new()
1068            .run(
1069                &agent,
1070                sampler.as_ref(),
1071                &mut state,
1072                TurnInput::Text("hi".into()),
1073                TurnOptions::default().with_deadline(Deadline::after(Duration::ZERO)),
1074            )
1075            .await
1076            .expect_err("deadline");
1077        assert_eq!(err.code(), ErrorCode::RuntimeDeadline);
1078    }
1079
1080    #[tokio::test]
1081    async fn structured_output_retry_then_ok() {
1082        let sampler = Arc::new(MockSampler::new());
1083        sampler.push_text("not-json");
1084        sampler.push_text(r#"{"ok":true}"#);
1085        let schema = json!({
1086            "type": "object",
1087            "properties": { "ok": { "type": "boolean" } },
1088            "required": ["ok"]
1089        });
1090        let agent = AgentBuilder::named("a")
1091            .model("mock")
1092            .output_schema(schema)
1093            .max_steps(8)
1094            .build()
1095            .expect("agent");
1096        let mut state = VecConversationState::new();
1097        let out = TurnRuntime::new()
1098            .run(
1099                &agent,
1100                sampler.as_ref(),
1101                &mut state,
1102                TurnInput::Text("give json".into()),
1103                TurnOptions::default(),
1104            )
1105            .await
1106            .expect("turn");
1107        assert_eq!(out.output_json, Some(json!({"ok": true})));
1108        assert!(out.steps >= 2);
1109    }
1110
1111    #[tokio::test]
1112    async fn structured_output_exhausted() {
1113        let sampler = Arc::new(MockSampler::new());
1114        // initial + STRUCTURED_OUTPUT_MAX_RETRIES bad attempts
1115        for _ in 0..=STRUCTURED_OUTPUT_MAX_RETRIES {
1116            sampler.push_text("nope");
1117        }
1118        let schema = json!({
1119            "type": "object",
1120            "properties": { "ok": { "type": "boolean" } },
1121            "required": ["ok"]
1122        });
1123        let agent = AgentBuilder::named("a")
1124            .model("mock")
1125            .output_schema(schema)
1126            .max_steps(16)
1127            .build()
1128            .expect("agent");
1129        let mut state = VecConversationState::new();
1130        let err = TurnRuntime::new()
1131            .run(
1132                &agent,
1133                sampler.as_ref(),
1134                &mut state,
1135                TurnInput::Text("give json".into()),
1136                TurnOptions::default(),
1137            )
1138            .await
1139            .expect_err("schema");
1140        assert_eq!(err.code(), ErrorCode::RuntimeStructuredOutput);
1141    }
1142
1143    #[tokio::test]
1144    async fn completion_gate_forces_retry() {
1145        use machi_agent::CompletionRequirement;
1146
1147        let sampler = Arc::new(MockSampler::new());
1148        // first final without submit tool → gate continues; second final after tools
1149        sampler.push_text("thinking");
1150        let id = ToolCallId::new("s1").expect("id");
1151        sampler.push_tools(Message::assistant_tools(vec![ToolCall {
1152            id,
1153            name: "submit".into(),
1154            arguments: json!({}),
1155        }]));
1156        sampler.push_text("done");
1157
1158        let agent = AgentBuilder::named("a")
1159            .model("mock")
1160            .tools(vec![Arc::new(SubmitTool)])
1161            .completion(CompletionRequirement {
1162                tool: "submit".into(),
1163                reminder: "please call submit".into(),
1164                max_retries: 3,
1165            })
1166            .max_steps(8)
1167            .build()
1168            .expect("agent");
1169        let mut state = VecConversationState::new();
1170        let out = TurnRuntime::new()
1171            .run(
1172                &agent,
1173                sampler.as_ref(),
1174                &mut state,
1175                TurnInput::Text("finish".into()),
1176                TurnOptions::default(),
1177            )
1178            .await
1179            .expect("turn");
1180        assert_eq!(out.output_text, "done");
1181        assert!(
1182            state
1183                .messages()
1184                .iter()
1185                .any(|m| m.text().contains("please call submit")),
1186            "expected completion reminder"
1187        );
1188    }
1189
1190    #[tokio::test]
1191    async fn stream_sample_path() {
1192        let sampler = Arc::new(MockSampler::new());
1193        sampler.push_text("streamed-out");
1194        let agent = AgentBuilder::named("a")
1195            .model("mock")
1196            .build()
1197            .expect("agent");
1198        let mut state = VecConversationState::new();
1199        let out = TurnRuntime::new()
1200            .run(
1201                &agent,
1202                sampler.as_ref(),
1203                &mut state,
1204                TurnInput::Text("hi".into()),
1205                TurnOptions::default().with_stream(true),
1206            )
1207            .await
1208            .expect("turn");
1209        assert_eq!(out.output_text, "streamed-out");
1210    }
1211
1212    #[tokio::test]
1213    async fn token_threshold_compaction() {
1214        use machi_compaction::TokenThreshold;
1215
1216        let sampler = Arc::new(MockSampler::new());
1217        sampler.push_text("ok");
1218        let agent = AgentBuilder::named("a")
1219            .model("mock")
1220            .build()
1221            .expect("agent");
1222        let mut state = VecConversationState::from_messages(vec![
1223            Message::system("sys"),
1224            Message::user("aaaaaaaaaa"),
1225            Message::user("bbbbbbbbbb"),
1226            Message::user("cccccccccc"),
1227            Message::user("dddddddddd"),
1228        ]);
1229        // token_estimate is coarse; force trigger with low max_tokens
1230        let strategy = TokenThreshold::new(1, 3).expect("strategy");
1231        let opts = TurnOptions::default().with_compaction(Arc::new(strategy));
1232        let _ = TurnRuntime::new()
1233            .run(
1234                &agent,
1235                sampler.as_ref(),
1236                &mut state,
1237                TurnInput::Text("new".into()),
1238                opts,
1239            )
1240            .await
1241            .expect("turn");
1242        assert_eq!(
1243            state.messages().first().map(Message::text).as_deref(),
1244            Some("sys")
1245        );
1246    }
1247}