1use 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#[derive(Debug, Clone)]
34pub enum TurnInput {
35 Text(String),
37 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#[derive(Clone)]
52pub struct TurnOptions {
53 pub max_steps: Option<usize>,
55 pub max_tool_concurrency: usize,
57 pub capability_mode: CapabilityMode,
59 pub cancel: CancellationToken,
61 pub deadline: Option<Deadline>,
63 pub session_id: Option<SessionId>,
65 pub agent_id: Option<AgentId>,
67 pub cwd: Option<PathBuf>,
69 pub compaction: Option<Arc<dyn CompactionStrategy>>,
71 pub approval: Arc<dyn ApprovalGate>,
73 pub approval_policy: ApprovalPolicy,
75 pub stop_gates: Option<Arc<GateChain>>,
77 pub metrics: SharedMetrics,
79 pub spawn_depth: Option<u32>,
81 pub max_output_tokens: Option<u32>,
83 pub use_stream: bool,
85 pub contributors: Arc<dyn TurnLifecycleContributor>,
87 pub interject_rx: Option<Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<Message>>>>,
89 pub context_window_tokens: Option<u32>,
91 pub context_overflow_ratio: f32,
93 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 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 #[must_use]
157 pub fn with_compaction(mut self, strategy: Arc<dyn CompactionStrategy>) -> Self {
158 self.compaction = Some(strategy);
159 self
160 }
161
162 #[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 #[must_use]
171 pub const fn with_capability(mut self, mode: CapabilityMode) -> Self {
172 self.capability_mode = mode;
173 self
174 }
175
176 #[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 #[must_use]
185 pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
186 self.cancel = cancel;
187 self
188 }
189
190 #[must_use]
192 pub fn with_approval(mut self, gate: Arc<dyn ApprovalGate>) -> Self {
193 self.approval = gate;
194 self
195 }
196
197 #[must_use]
199 pub const fn with_approval_policy(mut self, policy: ApprovalPolicy) -> Self {
200 self.approval_policy = policy;
201 self
202 }
203
204 #[must_use]
206 pub fn with_metrics(mut self, metrics: SharedMetrics) -> Self {
207 self.metrics = metrics;
208 self
209 }
210
211 #[must_use]
213 pub const fn with_deadline(mut self, deadline: Deadline) -> Self {
214 self.deadline = Some(deadline);
215 self
216 }
217
218 #[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 #[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 #[must_use]
234 pub fn with_contributors(mut self, contributors: Arc<dyn TurnLifecycleContributor>) -> Self {
235 self.contributors = contributors;
236 self
237 }
238
239 #[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 #[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#[derive(Debug, Clone)]
259pub struct TurnOutcome {
260 pub run_id: RunId,
262 pub output_text: String,
264 pub output_json: Option<Value>,
266 pub usage: Usage,
268 pub steps: usize,
270 pub cancelled: bool,
272}
273
274#[derive(Debug, Default, Clone, Copy)]
276pub struct TurnRuntime;
277
278impl TurnRuntime {
279 #[must_use]
281 pub const fn new() -> Self {
282 Self
283 }
284
285 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
486fn 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 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#[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 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 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 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 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}