1use myco_model::{
5 Content, GenerateOutput, Message, TokenUsage, ToolResult, ToolUse, TurnEndReason,
6 answer_content,
7};
8
9use crate::{CONTINUE_PROMPT, LOST_THINKING_PROMPT, lost_thinking};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12pub struct OperationId(u64);
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub enum PendingOperation {
18 Generation { operation: OperationId },
19 Tools { operation: OperationId },
20}
21
22#[derive(Debug, Clone)]
23pub enum Effect {
24 Generate {
25 operation: OperationId,
26 },
27 ExecuteTools {
28 operation: OperationId,
29 calls: Vec<ToolUse>,
30 },
31 Finished {
32 answer: Vec<Content>,
33 reason: TurnEndReason,
34 },
35 Cancelled,
36}
37
38#[derive(Debug, Clone)]
39enum Next {
40 Generate,
41 Finish {
42 answer: Vec<Content>,
43 reason: TurnEndReason,
44 },
45}
46
47#[derive(Debug, Clone)]
48enum Phase {
49 Ready,
50 GenerationFailed,
51 Finished {
52 answer: Vec<Content>,
53 reason: TurnEndReason,
54 },
55 Cancelled,
56 Generating(OperationId),
57 Tools {
58 operation: OperationId,
59 count: usize,
60 next: Next,
61 },
62}
63
64#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
65pub enum StateError {
66 #[error("agent has an outstanding operation")]
67 Busy,
68 #[error("completion does not match the outstanding operation")]
69 UnexpectedCompletion,
70 #[error("invalid context: {0}")]
71 InvalidContext(String),
72 #[error("invalid model response: {0}")]
73 InvalidResponse(String),
74}
75
76#[derive(Debug, Clone)]
79pub struct AgentState {
80 history: Vec<Message>,
81 context_size: crate::context_size::ContextSize,
82 last_usage: Option<TokenUsage>,
83 phase: Phase,
84 sequence: u64,
85 executing: bool,
86 run_usage: Option<TokenUsage>,
87 truncations: u32,
88 max_truncated_resumes: u32,
89}
90
91impl Default for AgentState {
92 fn default() -> Self {
93 Self {
94 history: Vec::new(),
95 context_size: Default::default(),
96 last_usage: None,
97 phase: Phase::Ready,
98 sequence: 0,
99 executing: false,
100 run_usage: None,
101 truncations: 0,
102 max_truncated_resumes: crate::DEFAULT_MAX_TRUNCATED_RESUMES,
103 }
104 }
105}
106
107impl AgentState {
108 pub fn history(&self) -> &[Message] {
109 &self.history
110 }
111
112 pub fn last_usage(&self) -> Option<TokenUsage> {
113 self.last_usage
114 }
115
116 pub(crate) fn context_tokens_estimate(&self) -> u64 {
117 self.context_size.tokens()
118 }
119
120 pub(crate) fn restore_context_size_hint(&mut self, tokens: Option<u64>) {
121 self.context_size.restore_hint(tokens);
122 }
123
124 fn push(&mut self, message: Message) {
125 self.context_size.append(&message);
126 self.history.push(message);
127 }
128
129 fn replace_history(&mut self, history: Vec<Message>, usage: Option<TokenUsage>) {
130 if (usage.is_none() || usage == self.last_usage) && history.starts_with(&self.history) {
131 for message in &history[self.history.len()..] {
132 self.context_size.append(message);
133 }
134 } else {
135 self.context_size = crate::context_size::ContextSize::restored(&history, usage);
136 }
137 self.history = history;
138 self.last_usage = usage;
139 }
140
141 pub fn run_usage(&self) -> Option<TokenUsage> {
142 self.run_usage
143 }
144
145 pub fn is_idle(&self) -> bool {
146 matches!(
147 self.phase,
148 Phase::Ready | Phase::GenerationFailed | Phase::Finished { .. } | Phase::Cancelled
149 )
150 }
151
152 pub fn pending_operation(&self) -> Option<PendingOperation> {
153 match self.phase {
154 Phase::Ready | Phase::GenerationFailed | Phase::Finished { .. } | Phase::Cancelled => {
155 None
156 }
157 Phase::Generating(operation) => Some(PendingOperation::Generation { operation }),
158 Phase::Tools { operation, .. } => Some(PendingOperation::Tools { operation }),
159 }
160 }
161
162 pub fn is_model_boundary(&self) -> bool {
165 !matches!(self.phase, Phase::Tools { .. })
166 }
167
168 pub fn effect(&self) -> Option<Effect> {
170 match &self.phase {
171 Phase::Ready | Phase::GenerationFailed => None,
172 Phase::Generating(operation) => Some(Effect::Generate {
173 operation: *operation,
174 }),
175 Phase::Tools { operation, .. } => {
176 let Some(Message::AssistantMessage { tool_uses, .. }) = self.history.last() else {
177 unreachable!()
178 };
179 Some(Effect::ExecuteTools {
180 operation: *operation,
181 calls: tool_uses.clone(),
182 })
183 }
184 Phase::Finished { answer, reason } => Some(Effect::Finished {
185 answer: answer.clone(),
186 reason: reason.clone(),
187 }),
188 Phase::Cancelled => Some(Effect::Cancelled),
189 }
190 }
191
192 pub fn begin_effect(&mut self, operation: OperationId) -> Result<(), StateError> {
194 if self.executing {
195 return Err(StateError::Busy);
196 }
197 match self.pending_operation() {
198 Some(
199 PendingOperation::Generation {
200 operation: expected,
201 }
202 | PendingOperation::Tools {
203 operation: expected,
204 },
205 ) if expected == operation => {
206 self.executing = true;
207 Ok(())
208 }
209 _ => Err(StateError::UnexpectedCompletion),
210 }
211 }
212
213 pub fn can_replace_at_boundary(&self) -> bool {
214 matches!(
215 self.phase,
216 Phase::Generating(_) | Phase::GenerationFailed | Phase::Finished { .. }
217 ) && !self.executing
218 }
219
220 pub fn cancel_at_boundary(&mut self) -> Result<(), StateError> {
221 if !self.can_replace_at_boundary() {
222 return Err(StateError::Busy);
223 }
224 self.phase = Phase::Cancelled;
225 Ok(())
226 }
227
228 pub fn replace_at_boundary(
231 &mut self,
232 history: Vec<Message>,
233 usage: Option<TokenUsage>,
234 ) -> Result<(), StateError> {
235 if !self.can_replace_at_boundary() {
236 return Err(StateError::Busy);
237 }
238 validate_context(&history)?;
239 self.replace_history(history, usage);
240 self.generate();
241 Ok(())
242 }
243
244 pub fn recover_interrupted(&mut self) -> Result<(), StateError> {
248 if self.executing {
249 let history = recover_checkpoint(self.history.clone(), self.pending_operation())?;
250 self.replace_history(history, self.last_usage);
251 } else if let Phase::Tools {
252 operation, count, ..
253 } = self.phase
254 {
255 let results = (0..count).map(|_| ToolResult::err(
256 "not executed: the run stopped before this tool batch began. Reconsider this action using the latest input before trying again.",
257 )).collect();
258 self.tools_completed(operation, results, true)?;
259 }
260 self.phase = Phase::Ready;
261 self.executing = false;
262 Ok(())
263 }
264
265 fn require_ready(&self) -> Result<(), StateError> {
266 if self.is_idle() {
267 Ok(())
268 } else {
269 Err(StateError::Busy)
270 }
271 }
272
273 pub fn replace_context(
274 &mut self,
275 history: Vec<Message>,
276 usage: Option<TokenUsage>,
277 ) -> Result<(), StateError> {
278 self.require_ready()?;
279 validate_context(&history)?;
280 self.replace_history(history, usage);
281 self.phase = Phase::Ready;
282 Ok(())
283 }
284
285 pub fn append_input(&mut self, message: Message) -> Result<(), StateError> {
286 self.require_ready()?;
287 if !matches!(message, Message::UserMessage { .. }) {
288 return Err(StateError::InvalidContext(
289 "input must be a user message".into(),
290 ));
291 }
292 self.phase = Phase::Ready;
293 self.push(message);
294 Ok(())
295 }
296
297 pub fn append_system(&mut self, parts: Vec<Content>) -> Result<(), StateError> {
300 if self.executing || !self.is_model_boundary() {
301 return Err(StateError::Busy);
302 }
303 if parts.is_empty()
304 || parts
305 .iter()
306 .any(|part| !matches!(part, Content::System { .. }))
307 {
308 return Err(StateError::InvalidContext(
309 "runtime observations must be system parts".into(),
310 ));
311 }
312 self.push(Message::UserMessage { content: parts });
313 if matches!(self.phase, Phase::Generating(_)) {
314 self.generate();
315 }
316 Ok(())
317 }
318
319 pub fn truncate_history(&mut self, index: usize) -> Result<Vec<Message>, StateError> {
320 self.require_ready()?;
321 let prefix = self.history.get(..index).ok_or_else(|| {
322 StateError::InvalidContext("rewind index is beyond the history".into())
323 })?;
324 validate_context(prefix)?;
325 let dropped = self.history.split_off(index);
326 self.context_size = crate::context_size::ContextSize::restored(&self.history, None);
327 self.last_usage = None;
328 self.phase = Phase::Ready;
329 Ok(dropped)
330 }
331
332 pub fn set_max_truncated_resumes(&mut self, resumes: u32) {
333 self.max_truncated_resumes = resumes;
334 }
335
336 pub fn start(&mut self) -> Result<Effect, StateError> {
339 self.require_ready()?;
340 if self.history.is_empty() {
341 return Err(StateError::InvalidContext(
342 "cannot run an empty context".into(),
343 ));
344 }
345 if !matches!(self.phase, Phase::GenerationFailed) {
346 self.run_usage = None;
347 self.truncations = 0;
348 }
349 Ok(self.generate())
350 }
351
352 fn operation(&mut self) -> OperationId {
353 self.sequence = self
354 .sequence
355 .checked_add(1)
356 .expect("operation sequence exhausted");
357 OperationId(self.sequence)
358 }
359
360 fn generate(&mut self) -> Effect {
361 let operation = self.operation();
362 self.phase = Phase::Generating(operation);
363 self.executing = false;
364 Effect::Generate { operation }
365 }
366
367 pub fn generation_failed(&mut self, operation: OperationId) -> Result<(), StateError> {
370 if !matches!(self.phase, Phase::Generating(id) if id == operation) {
371 return Err(StateError::UnexpectedCompletion);
372 }
373 self.phase = Phase::GenerationFailed;
374 self.executing = false;
375 Ok(())
376 }
377
378 pub(crate) fn append_generation_notice(
379 &mut self,
380 operation: OperationId,
381 text: String,
382 ) -> Result<(), StateError> {
383 if !matches!(self.phase, Phase::Generating(id) if id == operation) {
384 return Err(StateError::UnexpectedCompletion);
385 }
386 let notice = Content::System {
387 kind: "generation_notice".into(),
388 text,
389 data: serde_json::Value::Null,
390 };
391 match self.history.last_mut() {
394 Some(Message::UserMessage { content }) => {
395 self.context_size.append_part(¬ice);
396 content.push(notice);
397 }
398 Some(Message::ToolResults { tool_use_results }) if !tool_use_results.is_empty() => {
399 self.context_size.append_part(¬ice);
400 tool_use_results.last_mut().unwrap().content.push(notice);
401 }
402 _ => self.push(Message::UserMessage {
403 content: vec![notice],
404 }),
405 }
406 Ok(())
407 }
408
409 pub fn generated(
410 &mut self,
411 operation: OperationId,
412 output: GenerateOutput,
413 ) -> Result<Effect, StateError> {
414 if !matches!(self.phase, Phase::Generating(id) if id == operation) {
415 return Err(StateError::UnexpectedCompletion);
416 }
417 if output
418 .content
419 .iter()
420 .any(|part| matches!(part, Content::System { .. }))
421 {
422 self.generation_failed(operation)?;
423 return Err(StateError::InvalidResponse(
424 "model output contains a runtime-only system part".into(),
425 ));
426 }
427 if output.turn_end_reason == TurnEndReason::ToolUse && output.tool_uses.is_empty() {
428 self.generation_failed(operation)?;
429 return Err(StateError::InvalidResponse(
430 "turn ended in tool_use but streamed zero tool uses".into(),
431 ));
432 }
433 self.executing = false;
434 if let Some(usage) = output.usage {
435 self.context_size.observe_input(usage);
436 self.run_usage = Some(TokenUsage {
437 output_tokens: self
438 .run_usage
439 .map_or(0, |previous| previous.output_tokens)
440 .saturating_add(usage.output_tokens),
441 ..usage
442 });
443 self.last_usage = self.run_usage;
444 }
445 let answer = answer_content(&output.content);
446 let reason = output.turn_end_reason;
447 let calls = output.tool_uses;
448 let assistant = Message::AssistantMessage {
449 content: output.content,
450 tool_uses: calls.clone(),
451 turn_end_reason: Some(reason.clone()),
452 };
453 let thinking_only = lost_thinking(&assistant);
454 self.push(assistant);
455 self.phase = Phase::Ready;
456 let resume = if reason == TurnEndReason::MaxTokens {
457 self.truncations = self.truncations.saturating_add(1);
458 self.truncations <= self.max_truncated_resumes
459 } else {
460 self.truncations = 0;
461 false
462 };
463 let next = if reason == TurnEndReason::ToolUse || resume {
464 Next::Generate
465 } else {
466 Next::Finish { answer, reason }
467 };
468 if calls.is_empty() {
469 if resume {
470 self.push(Message::UserMessage {
471 content: vec![Content::System {
472 kind: "continuation".into(),
473 text: if thinking_only {
474 LOST_THINKING_PROMPT
475 } else {
476 CONTINUE_PROMPT
477 }
478 .into(),
479 data: serde_json::json!({"reason":"max_tokens","thinking_only":thinking_only}),
480 }],
481 });
482 }
483 Ok(self.advance(next))
484 } else {
485 let operation = self.operation();
486 self.phase = Phase::Tools {
487 operation,
488 count: calls.len(),
489 next,
490 };
491 Ok(Effect::ExecuteTools { operation, calls })
492 }
493 }
494
495 pub fn tools_completed(
498 &mut self,
499 operation: OperationId,
500 results: Vec<ToolResult>,
501 cancelled: bool,
502 ) -> Result<Effect, StateError> {
503 let Phase::Tools {
504 operation: expected,
505 count,
506 next,
507 } = &self.phase
508 else {
509 return Err(StateError::UnexpectedCompletion);
510 };
511 if operation != *expected {
512 return Err(StateError::UnexpectedCompletion);
513 }
514 if results.len() != *count {
515 return Err(StateError::InvalidContext(format!(
516 "expected {count} tool results, received {}",
517 results.len(),
518 )));
519 }
520 let next = next.clone();
521 self.executing = false;
522 self.push(Message::ToolResults {
523 tool_use_results: results,
524 });
525 if cancelled {
526 self.phase = Phase::Cancelled;
527 Ok(Effect::Cancelled)
528 } else {
529 Ok(self.advance(next))
530 }
531 }
532
533 fn advance(&mut self, next: Next) -> Effect {
534 match next {
535 Next::Generate => self.generate(),
536 Next::Finish { answer, reason } => {
537 self.phase = Phase::Finished {
538 answer: answer.clone(),
539 reason: reason.clone(),
540 };
541 Effect::Finished { answer, reason }
542 }
543 }
544 }
545}
546
547pub fn validate_context(history: &[Message]) -> Result<(), StateError> {
549 let mut pending = 0;
550 for (index, message) in history.iter().enumerate() {
551 match message {
552 Message::ToolResults { tool_use_results } => {
553 if pending == 0 || pending != tool_use_results.len() {
554 return Err(StateError::InvalidContext(format!(
555 "message {index} has {} results for {pending} tool calls",
556 tool_use_results.len(),
557 )));
558 }
559 pending = 0;
560 }
561 _ if pending != 0 => {
562 return Err(StateError::InvalidContext(format!(
563 "message {index} interrupts an unanswered tool batch",
564 )));
565 }
566 Message::AssistantMessage { tool_uses, .. } => pending = tool_uses.len(),
567 Message::UserMessage { .. } => {}
568 }
569 }
570 if pending != 0 {
571 return Err(StateError::InvalidContext(
572 "history ends with unanswered tool calls".into(),
573 ));
574 }
575 Ok(())
576}
577
578pub fn validate_checkpoint(
579 history: &[Message],
580 pending: Option<PendingOperation>,
581) -> Result<(), StateError> {
582 if matches!(pending, Some(PendingOperation::Tools { .. })) {
583 match history.split_last() {
584 Some((Message::AssistantMessage { tool_uses, .. }, prefix))
585 if !tool_uses.is_empty() =>
586 {
587 validate_context(prefix)
588 }
589 _ => Err(StateError::InvalidContext(
590 "pending tools require an unanswered assistant tool batch".into(),
591 )),
592 }
593 } else {
594 validate_context(history)
595 }
596}
597
598pub fn recover_checkpoint(
601 mut history: Vec<Message>,
602 pending: Option<PendingOperation>,
603) -> Result<Vec<Message>, StateError> {
604 validate_checkpoint(&history, pending)?;
605 let Some(pending) = pending else {
606 return Ok(history);
607 };
608 let text = match pending {
609 PendingOperation::Tools { .. } => {
610 let Some(Message::AssistantMessage { tool_uses, .. }) = history.last() else {
611 unreachable!()
612 };
613 let results = tool_uses.iter().map(|_| ToolResult::err(
614 "execution interrupted before the outcome was saved; effects are unknown. Inspect external state before retrying this action.",
615 )).collect();
616 history.push(Message::ToolResults {
617 tool_use_results: results,
618 });
619 "This runtime recovered an interrupted tool batch. Its results were not durably recorded. Calls may have executed; they have not been replayed. Inspect external state before repeating any action."
620 }
621 PendingOperation::Generation { .. } => {
622 "A generation was pending when the previous runtime stopped. No completed response from that operation was recorded. Continue from the saved observations."
623 }
624 };
625 history.push(Message::UserMessage {
626 content: vec![Content::System {
627 kind: "interrupted".into(),
628 text: text.into(),
629 data: serde_json::json!({"pending":pending}),
630 }],
631 });
632 Ok(history)
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use crate::test_support::{assistant, assistant_tool, tool_results, user};
639 use serde_json::json;
640
641 fn state() -> AgentState {
642 let mut state = AgentState::default();
643 state.append_input(user("task")).unwrap();
644 state
645 }
646
647 fn generation(effect: Effect) -> OperationId {
648 match effect {
649 Effect::Generate { operation } => operation,
650 other => panic!("expected generation, got {other:?}"),
651 }
652 }
653
654 fn response(reason: TurnEndReason, calls: usize) -> GenerateOutput {
655 GenerateOutput {
656 content: vec![Content::Text {
657 text: "answer".into(),
658 }],
659 tool_uses: (0..calls)
660 .map(|index| ToolUse {
661 name: "test".into(),
662 input: json!({"index": index}),
663 thought_signature: None,
664 })
665 .collect(),
666 turn_end_reason: reason,
667 usage: Some(TokenUsage {
668 input_tokens: 100,
669 output_tokens: 10,
670 cached_input_tokens: 20,
671 }),
672 }
673 }
674
675 fn continuation_text(machine: &AgentState) -> &str {
676 match machine.history().last() {
677 Some(Message::UserMessage { content }) => match content.as_slice() {
678 [Content::System { text, .. }] => text,
679 other => panic!("expected one continuation part, got {other:?}"),
680 },
681 other => panic!("expected a continuation turn, got {other:?}"),
682 }
683 }
684
685 #[test]
688 fn thinking_only_truncation_resumes_with_the_lost_thinking_prompt() {
689 let mut machine = state();
690 let first = generation(machine.start().unwrap());
691 let mut thinking_only = response(TurnEndReason::MaxTokens, 0);
692 thinking_only.content = vec![Content::Thinking {
693 text: "planning".into(),
694 signature: Some("sig".into()),
695 redacted: false,
696 }];
697 let next = generation(machine.generated(first, thinking_only).unwrap());
698 assert_eq!(continuation_text(&machine), LOST_THINKING_PROMPT);
699
700 let mut partial = response(TurnEndReason::MaxTokens, 0);
701 partial.content.insert(
702 0,
703 Content::Thinking {
704 text: "planning".into(),
705 signature: None,
706 redacted: false,
707 },
708 );
709 generation(machine.generated(next, partial).unwrap());
710 assert_eq!(continuation_text(&machine), CONTINUE_PROMPT);
711 }
712
713 #[test]
714 fn pure_transitions_replay_the_same_tool_loop_without_a_runtime() {
715 let mut left = state();
716 let mut right = left.clone();
717 for machine in [&mut left, &mut right] {
718 let op = generation(machine.start().unwrap());
719 let Effect::ExecuteTools { operation, calls } = machine
720 .generated(op, response(TurnEndReason::ToolUse, 2))
721 .unwrap()
722 else {
723 panic!()
724 };
725 assert_eq!(calls.len(), 2);
726 assert!(!machine.is_model_boundary());
727 let next = machine
728 .tools_completed(
729 operation,
730 vec![ToolResult::text("first"), ToolResult::err("second")],
731 false,
732 )
733 .unwrap();
734 let op = generation(next);
735 assert!(matches!(
736 machine
737 .generated(op, response(TurnEndReason::EndTurn, 0))
738 .unwrap(),
739 Effect::Finished {
740 reason: TurnEndReason::EndTurn,
741 ..
742 }
743 ));
744 assert_eq!(machine.last_usage().unwrap().output_tokens, 20);
745 validate_context(machine.history()).unwrap();
746 }
747 assert_eq!(format!("{left:?}"), format!("{right:?}"));
748 }
749
750 #[test]
751 fn context_growth_survives_rebinding_missing_usage_and_model_changes() {
752 let mut machine = state();
753 let op = generation(machine.start().unwrap());
754 machine
755 .generated(op, response(TurnEndReason::EndTurn, 0))
756 .unwrap();
757 let measured = machine.context_tokens_estimate();
758 machine
759 .replace_context(machine.history().to_vec(), machine.last_usage())
760 .unwrap();
761 assert_eq!(machine.context_tokens_estimate(), measured);
762 machine
763 .append_input(Message::UserMessage {
764 content: vec![Content::Text {
765 text: "x".repeat(3000),
766 }],
767 })
768 .unwrap();
769 let grown = machine.context_tokens_estimate();
770 assert!(grown >= measured + 1000);
771 let op = generation(machine.start().unwrap());
772 let mut unmeasured = response(TurnEndReason::EndTurn, 0);
773 unmeasured.usage = None;
774 machine.generated(op, unmeasured).unwrap();
775 assert!(machine.context_tokens_estimate() > grown);
776 let before_change = machine.context_tokens_estimate();
777 machine
778 .replace_context(machine.history().to_vec(), None)
779 .unwrap();
780 assert_eq!(machine.context_tokens_estimate(), before_change);
781 machine.truncate_history(1).unwrap();
782 assert!(machine.context_tokens_estimate() < measured);
783 }
784
785 #[test]
786 fn stale_completions_and_input_during_work_leave_state_unchanged() {
787 let mut machine = state();
788 let stale = generation(machine.start().unwrap());
789 machine.generation_failed(stale).unwrap();
790 let current = generation(machine.start().unwrap());
791 let before = format!("{machine:?}");
792 assert_eq!(
793 machine
794 .generated(stale, response(TurnEndReason::ToolUse, 1))
795 .unwrap_err(),
796 StateError::UnexpectedCompletion
797 );
798 assert_eq!(
799 machine.append_input(user("interjection")),
800 Err(StateError::Busy)
801 );
802 assert_eq!(
803 machine.replace_context(vec![user("replacement")], None),
804 Err(StateError::Busy)
805 );
806 assert!(matches!(machine.truncate_history(0), Err(StateError::Busy)));
807 assert_eq!(
808 machine.generation_failed(stale),
809 Err(StateError::UnexpectedCompletion)
810 );
811 assert_eq!(format!("{machine:?}"), before);
812 machine.generation_failed(current).unwrap();
813 }
814
815 #[test]
816 fn tool_batch_requires_matching_count_and_can_only_complete_once() {
817 let mut machine = state();
818 let generation = generation(machine.start().unwrap());
819 let Effect::ExecuteTools { operation, .. } = machine
820 .generated(generation, response(TurnEndReason::ToolUse, 2))
821 .unwrap()
822 else {
823 panic!()
824 };
825 let before = format!("{machine:?}");
826 assert!(
827 machine
828 .tools_completed(operation, vec![ToolResult::text("partial")], false)
829 .is_err()
830 );
831 assert_eq!(format!("{machine:?}"), before);
832 assert!(matches!(
833 machine
834 .tools_completed(
835 operation,
836 vec![ToolResult::text("finished"), ToolResult::err("cancelled")],
837 true
838 )
839 .unwrap(),
840 Effect::Cancelled
841 ));
842 validate_context(machine.history()).unwrap();
843 assert_eq!(
844 machine
845 .tools_completed(operation, vec![], false)
846 .unwrap_err(),
847 StateError::UnexpectedCompletion
848 );
849 machine.append_input(user("next turn")).unwrap();
850 }
851
852 #[test]
853 fn context_replacement_and_rewind_reject_split_or_orphaned_tool_batches() {
854 let mut machine = state();
855 let call = assistant_tool(None, "test", json!({}));
856 for history in [
857 vec![call.clone()],
858 vec![tool_results(&["orphan"])],
859 vec![call.clone(), user("interrupt"), tool_results(&["result"])],
860 vec![call.clone(), tool_results(&["extra", "extra"])],
861 vec![Message::ToolResults {
862 tool_use_results: vec![],
863 }],
864 ] {
865 assert!(machine.replace_context(history, None).is_err());
866 assert_eq!(machine.history().len(), 1);
867 }
868 machine
869 .replace_context(
870 vec![user("task"), call, tool_results(&["ok"]), assistant("done")],
871 None,
872 )
873 .unwrap();
874 assert!(machine.truncate_history(2).is_err());
875 assert!(machine.truncate_history(99).is_err());
876 assert_eq!(machine.history().len(), 4);
877 machine.truncate_history(1).unwrap();
878 }
879
880 #[test]
881 fn truncation_cap_is_an_explicit_finish_reason_and_resets_for_a_new_run() {
882 let mut machine = state();
883 machine.set_max_truncated_resumes(1);
884 let first = generation(machine.start().unwrap());
885 let next = machine
886 .generated(first, response(TurnEndReason::MaxTokens, 0))
887 .unwrap();
888 let second = generation(next);
889 assert!(matches!(
890 machine
891 .generated(second, response(TurnEndReason::MaxTokens, 0))
892 .unwrap(),
893 Effect::Finished {
894 reason: TurnEndReason::MaxTokens,
895 ..
896 }
897 ));
898 machine.append_input(user("new task")).unwrap();
899 let next = generation(machine.start().unwrap());
900 assert!(matches!(
901 machine
902 .generated(next, response(TurnEndReason::MaxTokens, 0))
903 .unwrap(),
904 Effect::Generate { .. }
905 ));
906 }
907
908 #[test]
909 fn restarting_failed_generation_preserves_usage_and_truncation_limits() {
910 let mut machine = state();
911 machine.set_max_truncated_resumes(1);
912 let first = generation(machine.start().unwrap());
913 let pending = generation(
914 machine
915 .generated(first, response(TurnEndReason::MaxTokens, 0))
916 .unwrap(),
917 );
918 machine.generation_failed(pending).unwrap();
919 let retried = generation(machine.start().unwrap());
920 assert_eq!(machine.run_usage().unwrap().output_tokens, 10);
921 assert!(matches!(
922 machine
923 .generated(retried, response(TurnEndReason::MaxTokens, 0))
924 .unwrap(),
925 Effect::Finished {
926 reason: TurnEndReason::MaxTokens,
927 ..
928 }
929 ));
930 assert_eq!(machine.run_usage().unwrap().output_tokens, 20);
931
932 machine.append_input(user("new task")).unwrap();
933 let fresh = generation(machine.start().unwrap());
934 assert!(machine.run_usage().is_none());
935 assert!(matches!(
936 machine
937 .generated(fresh, response(TurnEndReason::MaxTokens, 0))
938 .unwrap(),
939 Effect::Generate { .. }
940 ));
941 assert_eq!(machine.run_usage().unwrap().output_tokens, 10);
942 }
943
944 #[test]
945 fn invalid_outputs_leave_history_usage_and_truncation_budget_unchanged() {
946 let mut machine = state();
947 machine.set_max_truncated_resumes(1);
948 let first = generation(machine.start().unwrap());
949 let mut pending = generation(
950 machine
951 .generated(first, response(TurnEndReason::MaxTokens, 0))
952 .unwrap(),
953 );
954 let history = serde_json::to_value(machine.history()).unwrap();
955 let usage = machine.run_usage();
956 let mut system_output = response(TurnEndReason::EndTurn, 0);
957 system_output.content.push(Content::System {
958 kind: "invalid".into(),
959 text: "invalid".into(),
960 data: serde_json::Value::Null,
961 });
962 for output in [response(TurnEndReason::ToolUse, 0), system_output] {
963 machine.begin_effect(pending).unwrap();
964 assert!(matches!(
965 machine.generated(pending, output),
966 Err(StateError::InvalidResponse(_))
967 ));
968 assert_eq!(serde_json::to_value(machine.history()).unwrap(), history);
969 assert_eq!(machine.run_usage(), usage);
970 assert_eq!(machine.last_usage(), usage);
971 pending = generation(machine.start().unwrap());
972 }
973 assert!(matches!(
974 machine
975 .generated(pending, response(TurnEndReason::MaxTokens, 0))
976 .unwrap(),
977 Effect::Finished {
978 reason: TurnEndReason::MaxTokens,
979 ..
980 }
981 ));
982 }
983}