1use std::sync::Arc;
9
10use futures::StreamExt;
11use tokio_util::sync::CancellationToken;
12
13use flux_core::{Chunk, ContentBlock, Error, Message, Result, Usage};
14use flux_events::EventStore;
15use flux_provider::{Effort, Provider, Request};
16use flux_runtime::{
17 scope_runtime_turn, Executor, RuntimeTurnContext, SpawnTaskSupervisor, TurnIdentity,
18 SPAWN_CLEANUP_GRACE,
19};
20
21use crate::ast::{DraftAst, Node, NodeId};
22use crate::composites::DynamicComposites;
23use crate::model::StageOptions;
24use crate::registry::OpRegistry;
25use crate::runtime::{
26 execute_flow_traced, execute_flow_with_composites, resume_flow_with_composites,
27};
28use crate::state::FlowStore;
29use crate::AgentSink;
30use flux_lang::runtime::FlowOutcome;
31
32pub const DEFAULT_AGENT_LOOP_ITERATIONS: usize = 50;
34
35pub const MAX_AGENT_LOOP_ITERATIONS: usize = 1_000;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum BuiltinAgentLoop {
46 #[default]
49 Adaptive,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub enum AgentLoopSpec {
55 Builtin(BuiltinAgentLoop),
57 Flux(DraftAst),
59}
60
61impl Default for AgentLoopSpec {
62 fn default() -> Self {
63 Self::Builtin(BuiltinAgentLoop::Adaptive)
64 }
65}
66
67impl AgentLoopSpec {
68 pub fn parse(selector_or_source: &str) -> Result<Self> {
72 if selector_or_source.trim().eq_ignore_ascii_case("adaptive") {
73 return Ok(Self::default());
74 }
75 flux_lang::parse::parse(selector_or_source)
76 .map(Self::Flux)
77 .map_err(|error| Error::Other(format!("invalid explicit agent loop: {error}")))
78 }
79}
80
81pub struct FlowEngine {
84 pub provider: Arc<dyn Provider>,
86 pub executor: Arc<Executor>,
88 pub events: Arc<EventStore>,
89 pub flow: Arc<FlowStore>,
91 pub agent_loop: DraftAst,
93 pub loop_host: Arc<crate::loop_host::EngineLoopHost>,
95 pub composites: Arc<DynamicComposites>,
97 pub model: String,
98 pub system_prompt: String,
99 pub max_tokens: u32,
100 pub max_iterations: usize,
101 pub thinking: bool,
103 pub effort: Option<Effort>,
105 pub skills: Vec<flux_skill::Skill>,
108 pub compact_threshold_chars: usize,
111 pub groups: Vec<flux_evidence::ToolGroup>,
115 pub cwd: std::path::PathBuf,
117 ambient_signals: Vec<String>,
122 sticky_groups:
134 std::sync::Mutex<std::collections::HashMap<String, std::collections::HashSet<String>>>,
135 evidence_flushed: std::sync::atomic::AtomicUsize,
140 turn_gate: tokio::sync::Mutex<()>,
143}
144
145enum TurnProgram<'a> {
146 Adaptive,
147 Authored(&'a DraftAst),
148 Resume {
149 flow_name: Option<String>,
150 body: Vec<Node>,
151 node: NodeId,
152 },
153}
154
155#[derive(Clone, Copy, PartialEq, Eq)]
156enum TurnProgramKind {
157 Adaptive,
158 Authored,
159 Resume,
160}
161
162impl TurnProgram<'_> {
163 fn kind(&self) -> TurnProgramKind {
164 match self {
165 Self::Adaptive => TurnProgramKind::Adaptive,
166 Self::Authored(_) => TurnProgramKind::Authored,
167 Self::Resume { .. } => TurnProgramKind::Resume,
168 }
169 }
170
171 fn label<'a>(&'a self, user_input: Option<&'a str>) -> &'a str {
172 match self {
173 Self::Authored(flow) => flow.name.as_deref().unwrap_or("(flow start)"),
174 Self::Adaptive | Self::Resume { .. } => user_input.unwrap_or_default(),
175 }
176 }
177}
178
179struct SuspensionWrite {
180 flow_name: Option<String>,
181 body: Vec<Node>,
182 node: NodeId,
183 source: String,
184}
185
186struct TurnTerminal {
187 outcome: &'static str,
188 steps: u32,
189 answer: String,
190 cancelled: bool,
191 consume_checkpoint: bool,
192 suspension: Option<SuspensionWrite>,
193}
194
195struct TurnLifecycle {
196 accounting: TurnAccounting,
197 channel: Arc<std::sync::Mutex<dyn AgentSink>>,
198 receiver: tokio::sync::mpsc::UnboundedReceiver<crate::loop_host::SinkEvent>,
199 runtime: RuntimeTurnContext,
200 spawn_supervisor: Arc<SpawnTaskSupervisor>,
201}
202
203struct TurnAccounting {
204 turn_id: i64,
205 iteration_base: usize,
206 subagent_base: usize,
207}
208
209impl FlowEngine {
210 #[allow(clippy::too_many_arguments)]
215 pub fn assemble(
216 provider: Arc<dyn Provider>,
217 executor: Executor,
218 events: Arc<EventStore>,
219 flow: FlowStore,
220 model: String,
221 system_prompt: String,
222 max_tokens: u32,
223 max_iterations: usize,
224 skills: Vec<flux_skill::Skill>,
225 compact_threshold_chars: usize,
226 groups: Vec<flux_evidence::ToolGroup>,
227 cwd: std::path::PathBuf,
228 ) -> Result<Self> {
229 Self::assemble_with_loop(
230 provider,
231 executor,
232 events,
233 flow,
234 model,
235 system_prompt,
236 max_tokens,
237 max_iterations,
238 skills,
239 compact_threshold_chars,
240 groups,
241 cwd,
242 AgentLoopSpec::default(),
243 )
244 }
245
246 #[allow(clippy::too_many_arguments)]
248 pub fn assemble_with_loop(
249 provider: Arc<dyn Provider>,
250 executor: Executor,
251 events: Arc<EventStore>,
252 flow: FlowStore,
253 model: String,
254 system_prompt: String,
255 max_tokens: u32,
256 max_iterations: usize,
257 skills: Vec<flux_skill::Skill>,
258 compact_threshold_chars: usize,
259 groups: Vec<flux_evidence::ToolGroup>,
260 cwd: std::path::PathBuf,
261 agent_loop: AgentLoopSpec,
262 ) -> Result<Self> {
263 let flow = Arc::new(flow);
264 let composites = Arc::new(DynamicComposites::load(executor.context().system.as_ref())?);
265 let opts = StageOptions {
266 max_tokens,
267 ..Default::default()
268 };
269 let init_sink: Arc<std::sync::Mutex<dyn AgentSink>> =
271 Arc::new(std::sync::Mutex::new(NullSink));
272 let (executor, loop_host) = crate::loop_host::EngineLoopHost::install(
273 executor,
274 provider.clone(),
275 model.clone(),
276 Some(system_prompt.clone()),
277 flow.clone(),
278 composites.clone(),
279 String::new(),
280 init_sink,
281 opts,
282 );
283 loop_host.set_groups(groups.clone());
284 executor.allow(&[
287 "detect_intent",
288 "explore",
289 "approve_batch",
290 "execute_batch",
291 "present_results",
292 "ai_segment",
293 "observe",
294 "evidence",
295 "metrics",
296 ]);
297 composites.validate_base(executor.registry())?;
298 let agent_loop = load_agent_loop_with_iterations(agent_loop, max_iterations)?;
299 validate_agent_loop(
300 &agent_loop,
301 executor.registry(),
302 composites.active_for_session(""),
303 )?;
304 Ok(FlowEngine {
305 provider,
306 executor,
307 events,
308 flow,
309 agent_loop,
310 loop_host,
311 composites,
312 model,
313 system_prompt,
314 max_tokens,
315 max_iterations,
316 thinking: false,
317 effort: None,
318 skills,
319 compact_threshold_chars,
320 groups,
321 cwd,
322 ambient_signals: Vec::new(),
323 sticky_groups: std::sync::Mutex::new(std::collections::HashMap::new()),
324 evidence_flushed: std::sync::atomic::AtomicUsize::new(0),
325 turn_gate: tokio::sync::Mutex::new(()),
326 })
327 }
328
329 pub fn with_ambient_signals(mut self, signals: Vec<String>) -> Self {
334 self.ambient_signals = signals;
335 self
336 }
337
338 pub fn with_reasoning(mut self, thinking: bool, effort: Option<Effort>) -> Self {
341 self.thinking = thinking;
342 self.effort = effort;
343 self.loop_host.set_reasoning(thinking, effort);
344 self
345 }
346
347 pub fn switch_model_for_session(
350 &mut self,
351 session_id: &str,
352 provider: Arc<dyn Provider>,
353 model: String,
354 ) -> Result<()> {
355 self.events.set_model(session_id, &model)?;
356 self.loop_host.set_model(provider.clone(), model.clone());
357 self.provider = provider;
358 self.model = model;
359 Ok(())
360 }
361
362 fn begin_turn_lifecycle(
363 &self,
364 session_id: &str,
365 label: &str,
366 user_message: Option<&str>,
367 sink: &mut dyn AgentSink,
368 cancel: &CancellationToken,
369 identity: &TurnIdentity,
370 ) -> Result<TurnLifecycle> {
371 let skill_input = user_message.unwrap_or_default();
372 self.executor.begin_cache_turn();
374 if let Some(message) = user_message {
375 self.events
376 .record_message(session_id, &Message::user_text(message))?;
377 }
378 let turn_id = self
379 .events
380 .begin_turn(
381 session_id,
382 label,
383 &flux_core::canonical_model_spec(Some(self.provider.name()), &self.model),
384 )
385 .unwrap_or(-1);
386 self.executor.observe(flux_evidence::Observation::new(
387 "turn.identity",
388 flux_evidence::Phase::Turn,
389 serde_json::json!({
390 "caller": identity.caller().principal.id.as_str(),
391 "source": identity.caller().source.as_str(),
392 "trust": identity.trust(),
393 }),
394 ));
395 let iteration_base = self.executor.evidence().by_kind("turn.iteration").count();
396 let subagent_base = self.executor.evidence().by_kind("subagent.usage").count();
397 let base_system = self.base_system_with_skills(skill_input, sink);
398 let advertised = self.surfaced_for_turn(session_id, skill_input, sink);
399 let (sender, receiver) =
400 tokio::sync::mpsc::unbounded_channel::<crate::loop_host::SinkEvent>();
401 let channel: Arc<std::sync::Mutex<dyn AgentSink>> = Arc::new(std::sync::Mutex::new(
402 crate::loop_host::ChannelSink::new(sender),
403 ));
404 let activity = self.loop_host.set_turn(
405 session_id.to_string(),
406 Some(base_system),
407 channel.clone(),
408 Some(advertised),
409 Some((self.events.clone(), turn_id)),
410 );
411 let spawn_supervisor = Arc::new(SpawnTaskSupervisor::with_cancel(cancel.child_token()));
412 let runtime = RuntimeTurnContext::new()
413 .with_cancel(cancel.clone())
414 .with_session(session_id)
415 .with_spawn_activity_sink(activity)
416 .with_spawn_supervisor(spawn_supervisor.clone())
417 .with_identity(identity.clone());
418 if crate::cassette::enabled() {
419 self.flow
420 .set_cassette(Some(Arc::new(crate::cassette::CassetteScope::Record(
421 crate::cassette::RecordScope::new(self.events.clone(), session_id),
422 ))));
423 } else {
424 self.flow.set_cassette(None);
425 }
426 Ok(TurnLifecycle {
427 accounting: TurnAccounting {
428 turn_id,
429 iteration_base,
430 subagent_base,
431 },
432 channel,
433 receiver,
434 runtime,
435 spawn_supervisor,
436 })
437 }
438
439 async fn race_turn<F>(
440 lifecycle: TurnLifecycle,
441 sink: &mut dyn AgentSink,
442 cancel: &CancellationToken,
443 future: F,
444 ) -> (Option<Result<FlowOutcome>>, TurnAccounting)
445 where
446 F: std::future::Future<Output = Result<FlowOutcome>>,
447 {
448 let TurnLifecycle {
449 accounting,
450 channel: _,
451 mut receiver,
452 runtime,
453 spawn_supervisor,
454 } = lifecycle;
455 let reveal = show_loop();
456 let outcome = scope_runtime_turn(runtime, async {
457 tokio::pin!(future);
458 loop {
459 tokio::select! {
460 biased;
461 _ = cancel.cancelled() => break None,
462 event = receiver.recv() => {
463 if let Some(event) = event {
464 drain_event(event, sink, reveal);
465 }
466 }
467 result = &mut future => break Some(result),
468 }
469 }
470 })
471 .await;
472 spawn_supervisor.shutdown(SPAWN_CLEANUP_GRACE).await;
476 while let Ok(event) = receiver.try_recv() {
477 drain_event(event, sink, reveal);
478 }
479 (outcome, accounting)
480 }
481
482 fn finish_turn_lifecycle(
483 &self,
484 session_id: &str,
485 sink: &mut dyn AgentSink,
486 accounting: &TurnAccounting,
487 mut terminal: TurnTerminal,
488 ) -> Result<()> {
489 let persistence = if let Some(suspension) = terminal.suspension.take() {
490 self.flow.save_suspension(
491 session_id,
492 suspension.flow_name.as_deref(),
493 &suspension.body,
494 suspension.node,
495 &suspension.source,
496 )
497 } else if terminal.consume_checkpoint {
498 self.flow.clear_suspension(session_id)
499 } else {
500 Ok(())
501 };
502 if let Err(error) = persistence {
503 terminal.outcome = "error";
504 terminal.cancelled = false;
505 terminal.answer = format!(
506 "The turn finished, but its continuation checkpoint could not be persisted — {error}"
507 );
508 }
509 sink.text_delta(&terminal.answer);
510 let usage =
511 self.record_resume_usage(session_id, accounting.turn_id, accounting.subagent_base);
512 let _ = self.events.end_turn(
513 session_id,
514 accounting.turn_id,
515 terminal.outcome,
516 terminal.steps,
517 &terminal.answer,
518 usage.clone(),
519 );
520 self.finish_turn(
521 session_id,
522 accounting.turn_id,
523 sink,
524 &terminal.answer,
525 terminal.cancelled,
526 usage,
527 )
528 }
529
530 pub async fn run_turn(
532 &self,
533 session_id: &str,
534 user_input: &str,
535 sink: &mut dyn AgentSink,
536 ) -> Result<()> {
537 self.run_turn_cancellable(session_id, user_input, sink, &CancellationToken::new())
538 .await
539 }
540
541 pub async fn run_turn_cancellable(
546 &self,
547 session_id: &str,
548 user_input: &str,
549 sink: &mut dyn AgentSink,
550 cancel: &CancellationToken,
551 ) -> Result<()> {
552 let _turn = self.turn_gate.lock().await;
553 let identity = self.executor.identity().snapshot();
554 self.run_turn_locked(session_id, user_input, sink, cancel, identity)
555 .await
556 }
557
558 pub async fn run_turn_as(
561 &self,
562 session_id: &str,
563 user_input: &str,
564 sink: &mut dyn AgentSink,
565 identity: TurnIdentity,
566 ) -> Result<()> {
567 self.run_turn_cancellable_as(
568 session_id,
569 user_input,
570 sink,
571 &CancellationToken::new(),
572 identity,
573 )
574 .await
575 }
576
577 pub async fn run_turn_cancellable_as(
579 &self,
580 session_id: &str,
581 user_input: &str,
582 sink: &mut dyn AgentSink,
583 cancel: &CancellationToken,
584 identity: TurnIdentity,
585 ) -> Result<()> {
586 let _turn = self.turn_gate.lock().await;
587 self.run_turn_locked(session_id, user_input, sink, cancel, identity)
588 .await
589 }
590
591 async fn run_turn_locked(
593 &self,
594 session_id: &str,
595 user_input: &str,
596 sink: &mut dyn AgentSink,
597 cancel: &CancellationToken,
598 identity: TurnIdentity,
599 ) -> Result<()> {
600 let program = match self.flow.load_suspension(session_id)? {
601 Some((flow_name, body, node, _source)) => TurnProgram::Resume {
602 flow_name,
603 body,
604 node,
605 },
606 None => TurnProgram::Adaptive,
607 };
608 self.run_turn_lifecycle(
609 session_id,
610 Some(user_input),
611 program,
612 sink,
613 cancel,
614 identity,
615 )
616 .await
617 }
618
619 async fn run_turn_lifecycle(
620 &self,
621 session_id: &str,
622 user_input: Option<&str>,
623 program: TurnProgram<'_>,
624 sink: &mut dyn AgentSink,
625 cancel: &CancellationToken,
626 identity: TurnIdentity,
627 ) -> Result<()> {
628 let lifecycle = self.begin_turn_lifecycle(
629 session_id,
630 program.label(user_input),
631 user_input,
632 sink,
633 cancel,
634 &identity,
635 )?;
636 let future = self.execute_turn_program(
637 session_id,
638 user_input.unwrap_or_default(),
639 &program,
640 lifecycle.channel.clone(),
641 cancel,
642 );
643 let (outcome, accounting) = Self::race_turn(lifecycle, sink, cancel, future).await;
644 let terminal = self.turn_terminal(&program, outcome, accounting.iteration_base);
645 self.finish_turn_lifecycle(session_id, sink, &accounting, terminal)
646 }
647
648 async fn execute_turn_program(
649 &self,
650 session_id: &str,
651 user_input: &str,
652 program: &TurnProgram<'_>,
653 channel: Arc<std::sync::Mutex<dyn AgentSink>>,
654 cancel: &CancellationToken,
655 ) -> Result<FlowOutcome> {
656 let mut output = crate::loop_host::SharedSink::new(channel);
657 match program {
658 TurnProgram::Adaptive => {
659 let (compaction, usage) = self
660 .compaction_attempt(session_id, &mut output, cancel)
661 .await;
662 if let Some(usage) = usage {
663 self.loop_host
664 .record_external_call(self.provider.name(), &self.model, usage);
665 }
666 if let Err(error) = compaction {
667 return Err(Error::Other(format!("compaction: {}", model_error(&error))));
668 }
669 execute_flow_traced(
670 &self.flow,
671 &self.executor,
672 session_id,
673 &self.agent_loop,
674 &mut output,
675 trace_loop(),
676 )
677 .await
678 .map_err(|error| Error::Other(error.to_string()))
679 }
680 TurnProgram::Authored(flow) => {
681 self.composites
682 .ensure_session_loaded(&self.flow, session_id)?;
683 let composites = self.composites.active_for_session(session_id);
684 execute_flow_with_composites(
685 &self.flow,
686 &self.executor,
687 session_id,
688 flow,
689 &composites,
690 &mut output,
691 )
692 .await
693 .map_err(|error| Error::Other(error.to_string()))
694 }
695 TurnProgram::Resume {
696 flow_name,
697 body,
698 node,
699 } => {
700 self.composites
701 .ensure_session_loaded(&self.flow, session_id)?;
702 let composites = self.composites.active_for_session(session_id);
703 resume_flow_with_composites(
704 &self.flow,
705 &self.executor,
706 session_id,
707 flow_name.as_deref(),
708 body,
709 *node,
710 flux_lang::ast::Value::String(user_input.to_string()),
711 &composites,
712 &mut output,
713 )
714 .await
715 .map_err(|error| Error::Other(error.to_string()))
716 }
717 }
718 }
719
720 fn turn_terminal(
721 &self,
722 program: &TurnProgram<'_>,
723 outcome: Option<Result<FlowOutcome>>,
724 iteration_base: usize,
725 ) -> TurnTerminal {
726 let kind = program.kind();
727 let Some(outcome) = outcome else {
728 return TurnTerminal {
729 outcome: "cancelled",
730 steps: 0,
731 answer: "(turn cancelled)".into(),
732 cancelled: true,
733 consume_checkpoint: false,
734 suspension: None,
735 };
736 };
737 let outcome = match outcome {
738 Ok(outcome) => outcome,
739 Err(error) => {
740 let error = error.to_string();
741 let answer = match kind {
742 TurnProgramKind::Adaptive if error.starts_with("compaction: ") => format!(
743 "I couldn't compact the conversation before continuing — {}",
744 error.trim_start_matches("compaction: ")
745 ),
746 TurnProgramKind::Adaptive => {
747 format!("I couldn't complete the turn — {error}")
748 }
749 TurnProgramKind::Authored => {
750 format!("The flow failed to start — {error}")
751 }
752 TurnProgramKind::Resume => {
753 format!("The resumed flow failed — {error}")
754 }
755 };
756 return TurnTerminal {
757 outcome: "error",
758 steps: 0,
759 answer,
760 cancelled: false,
761 consume_checkpoint: false,
762 suspension: None,
763 };
764 }
765 };
766
767 if let Some(suspension) = &outcome.suspension {
768 let (flow_name, body) = match program {
769 TurnProgram::Adaptive => {
770 (self.agent_loop.name.clone(), self.agent_loop.body.clone())
771 }
772 TurnProgram::Authored(flow) => (flow.name.clone(), flow.body.clone()),
773 TurnProgram::Resume {
774 flow_name, body, ..
775 } => (flow_name.clone(), body.clone()),
776 };
777 let steps = if kind == TurnProgramKind::Adaptive {
778 self.executor
779 .evidence()
780 .by_kind("turn.iteration")
781 .count()
782 .saturating_sub(iteration_base) as u32
783 } else {
784 outcome.steps as u32
785 };
786 return TurnTerminal {
787 outcome: "suspended",
788 steps,
789 answer: suspension_prompt(&outcome),
790 cancelled: false,
791 consume_checkpoint: false,
792 suspension: Some(SuspensionWrite {
793 flow_name,
794 body,
795 node: suspension.node,
796 source: suspension.source.clone(),
797 }),
798 };
799 }
800
801 match kind {
802 TurnProgramKind::Adaptive => {
803 let answer = outcome.result.trim().to_string();
804 let (answer, status) = if answer.is_empty() {
805 (
806 format!(
807 "Reached the maximum of {} adaptive iterations for this turn; stopping.",
808 self.max_iterations
809 ),
810 "max_iter",
811 )
812 } else {
813 (answer, "ok")
814 };
815 TurnTerminal {
816 outcome: status,
817 steps: self
818 .executor
819 .evidence()
820 .by_kind("turn.iteration")
821 .count()
822 .saturating_sub(iteration_base) as u32,
823 answer,
824 cancelled: false,
825 consume_checkpoint: false,
826 suspension: None,
827 }
828 }
829 TurnProgramKind::Authored => TurnTerminal {
830 outcome: "completed",
831 steps: outcome.steps as u32,
832 answer: if outcome.result.trim().is_empty() {
833 format!("Flow completed ({} step(s)).", outcome.steps)
834 } else {
835 outcome.result.trim().to_string()
836 },
837 cancelled: false,
838 consume_checkpoint: false,
839 suspension: None,
840 },
841 TurnProgramKind::Resume => TurnTerminal {
842 outcome: "resumed",
843 steps: outcome.steps as u32,
844 answer: if outcome.result.trim().is_empty() {
845 format!("Resumed and completed ({} step(s)).", outcome.steps)
846 } else {
847 outcome.result.trim().to_string()
848 },
849 cancelled: false,
850 consume_checkpoint: true,
851 suspension: None,
852 },
853 }
854 }
855
856 fn surfaced_for_turn(
857 &self,
858 session_id: &str,
859 user_input: &str,
860 sink: &mut dyn AgentSink,
861 ) -> std::collections::HashSet<String> {
862 let (advertised, surfaced) = surfaced_op_names(
863 self.executor.registry(),
864 &self.groups,
865 &self.cwd,
866 &self.sticky_groups,
867 session_id,
868 &self.ambient_signals,
869 user_input,
870 );
871 if let Some(surfaced) = surfaced.as_ref() {
872 self.record_active_groups(surfaced, sink);
873 }
874 advertised
875 }
876
877 fn record_active_groups(&self, surfaced: &SurfacedGroups, sink: &mut dyn AgentSink) {
881 let mut names: Vec<&str> = surfaced.active.iter().map(String::as_str).collect();
882 names.sort_unstable();
883 let mut signals: Vec<&str> = surfaced.signals.iter().map(String::as_str).collect();
884 signals.sort_unstable();
885 let obs = flux_evidence::Observation::new(
886 "groups.active",
887 flux_evidence::Phase::Turn,
888 serde_json::json!({ "groups": names, "signals": signals }),
889 );
890 self.executor.observe(obs.clone());
891 sink.observation(&obs);
892 }
893
894 fn base_system_with_skills(&self, _user_input: &str, sink: &mut dyn AgentSink) -> String {
897 let mut base_system = self.system_prompt.clone();
898 for skill in &self.skills {
899 base_system.push_str(&format!(
900 "\n\n<skill name=\"{}\">\n{}\n</skill>",
901 skill.name, skill.body
902 ));
903 let obs = flux_evidence::Observation::new(
904 "skill.activated",
905 flux_evidence::Phase::Turn,
906 serde_json::json!({ "skill": skill.name }),
907 );
908 self.executor.observe(obs.clone());
909 sink.observation(&obs);
910 }
911 base_system
912 }
913
914 fn finish_turn(
920 &self,
921 session_id: &str,
922 turn_id: i64,
923 sink: &mut dyn AgentSink,
924 answer: &str,
925 cancelled: bool,
926 usage: Option<Usage>,
927 ) -> Result<()> {
928 if cancelled {
929 let obs = flux_evidence::Observation::new(
930 "turn.cancelled",
931 flux_evidence::Phase::Turn,
932 serde_json::json!({}),
933 );
934 self.executor.observe(obs.clone());
935 sink.observation(&obs);
936 }
937 self.flush_observations(session_id, turn_id);
942 self.composites.clear_turn(session_id);
943 self.flow.set_cassette(None);
944 self.events.record_message(
945 session_id,
946 &Message::assistant(vec![ContentBlock::Text {
947 text: answer.to_string(),
948 }]),
949 )?;
950 sink.turn_end(usage);
951 Ok(())
952 }
953
954 fn flush_observations(&self, session_id: &str, turn_id: i64) {
966 let redactor = &self.executor.context().redactor;
967 let log = self.executor.evidence();
968 let all = log.all();
969 let start = self
970 .evidence_flushed
971 .load(std::sync::atomic::Ordering::SeqCst)
972 .min(all.len());
973 let written = flush_tail(&all[start..], |obs| {
978 let redacted = redact_observation(redactor, obs);
979 self.events
980 .record_observation(session_id, turn_id, &redacted)
981 });
982 self.evidence_flushed
983 .store(start + written, std::sync::atomic::Ordering::SeqCst);
984 }
985
986 fn turn_usage(&self, subagent_calls: &[(String, Usage)]) -> Option<Usage> {
992 let mut usage = self.loop_host.turn_usage();
993 for (_, call) in subagent_calls {
994 usage.sum_independent(call);
995 }
996 (usage.total() > 0).then_some(usage)
997 }
998
999 fn subagent_calls_since(&self, base: usize) -> Vec<(String, Usage)> {
1004 self.executor
1005 .evidence()
1006 .by_kind("subagent.usage")
1007 .skip(base)
1008 .filter_map(|o| {
1009 let model = o.data.get("model")?.as_str()?.to_string();
1010 let usage: Usage = serde_json::from_value(o.data.get("usage")?.clone()).ok()?;
1011 Some((model, usage))
1012 })
1013 .collect()
1014 }
1015
1016 fn record_call_usage_events(
1025 &self,
1026 session_id: &str,
1027 turn_id: i64,
1028 subagent_calls: &[(String, Usage)],
1029 ) {
1030 for (model, usage) in self.loop_host.turn_calls() {
1034 if usage.total() == 0 {
1035 continue;
1036 }
1037 let _ = self
1038 .events
1039 .record_call_usage(session_id, turn_id, &model, usage);
1040 }
1041 for (model, usage) in subagent_calls {
1042 if usage.total() == 0 {
1043 continue;
1044 }
1045 let _ = self
1046 .events
1047 .record_call_usage(session_id, turn_id, model, usage.clone());
1048 }
1049 }
1050
1051 pub async fn start_flow_turn(
1063 &self,
1064 session_id: &str,
1065 flow: &DraftAst,
1066 sink: &mut dyn AgentSink,
1067 ) -> Result<()> {
1068 self.start_flow_turn_cancellable(session_id, flow, sink, &CancellationToken::new())
1069 .await
1070 }
1071
1072 pub async fn start_flow_turn_cancellable(
1074 &self,
1075 session_id: &str,
1076 flow: &DraftAst,
1077 sink: &mut dyn AgentSink,
1078 cancel: &CancellationToken,
1079 ) -> Result<()> {
1080 let _turn = self.turn_gate.lock().await;
1081 let identity = self.executor.identity().snapshot();
1082 self.start_flow_turn_locked(session_id, flow, sink, cancel, identity)
1083 .await
1084 }
1085
1086 pub async fn start_flow_turn_as(
1088 &self,
1089 session_id: &str,
1090 flow: &DraftAst,
1091 sink: &mut dyn AgentSink,
1092 identity: TurnIdentity,
1093 ) -> Result<()> {
1094 self.start_flow_turn_cancellable_as(
1095 session_id,
1096 flow,
1097 sink,
1098 &CancellationToken::new(),
1099 identity,
1100 )
1101 .await
1102 }
1103
1104 pub async fn start_flow_turn_cancellable_as(
1106 &self,
1107 session_id: &str,
1108 flow: &DraftAst,
1109 sink: &mut dyn AgentSink,
1110 cancel: &CancellationToken,
1111 identity: TurnIdentity,
1112 ) -> Result<()> {
1113 let _turn = self.turn_gate.lock().await;
1114 self.start_flow_turn_locked(session_id, flow, sink, cancel, identity)
1115 .await
1116 }
1117
1118 async fn start_flow_turn_locked(
1119 &self,
1120 session_id: &str,
1121 flow: &DraftAst,
1122 sink: &mut dyn AgentSink,
1123 cancel: &CancellationToken,
1124 identity: TurnIdentity,
1125 ) -> Result<()> {
1126 self.run_turn_lifecycle(
1127 session_id,
1128 None,
1129 TurnProgram::Authored(flow),
1130 sink,
1131 cancel,
1132 identity,
1133 )
1134 .await
1135 }
1136
1137 fn record_resume_usage(
1143 &self,
1144 session_id: &str,
1145 turn_id: i64,
1146 subagent_base: usize,
1147 ) -> Option<Usage> {
1148 let subagent_calls = self.subagent_calls_since(subagent_base);
1149 self.record_call_usage_events(session_id, turn_id, &subagent_calls);
1150 self.turn_usage(&subagent_calls)
1151 }
1152
1153 pub async fn maybe_compact(
1158 &self,
1159 session_id: &str,
1160 sink: &mut dyn AgentSink,
1161 cancel: &CancellationToken,
1162 ) -> Result<()> {
1163 self.compaction_attempt(session_id, sink, cancel).await.0
1164 }
1165
1166 async fn compaction_attempt(
1171 &self,
1172 session_id: &str,
1173 sink: &mut dyn AgentSink,
1174 cancel: &CancellationToken,
1175 ) -> (Result<()>, Option<Usage>) {
1176 if self.compact_threshold_chars == 0 {
1177 return (Ok(()), None);
1178 }
1179 let messages = match self.events.conversation(session_id) {
1180 Ok(messages) => messages,
1181 Err(error) => return (Err(error), None),
1182 };
1183 if messages.len() < 4 {
1184 return (Ok(()), None);
1185 }
1186 let total: usize = messages
1187 .iter()
1188 .map(|m| serde_json::to_string(m).map(|s| s.len()).unwrap_or(0))
1189 .sum();
1190 if total <= self.compact_threshold_chars {
1191 return (Ok(()), None);
1192 }
1193
1194 let keep = 2.min(messages.len());
1195 let mut split = messages.len() - keep;
1196 while split > 0 && has_tool_result(&messages[split]) {
1197 split -= 1;
1198 }
1199 if split == 0 {
1200 return (Ok(()), None); }
1202 let (old, recent) = messages.split_at(split);
1203
1204 let mut transcript = String::new();
1205 for m in old {
1206 let t = m.text();
1207 if !t.trim().is_empty() {
1208 transcript.push_str(t.trim());
1209 transcript.push('\n');
1210 }
1211 }
1212 let prompt = format!(
1213 "Summarize the earlier conversation into a compact set of durable facts, decisions, and \
1214 open threads. Preserve file paths, names, and numbers. Be terse.\n\n{transcript}"
1215 );
1216 let req = Request::new(self.model.clone(), prompt)
1217 .with_max_tokens(1024)
1218 .with_thinking(self.thinking);
1219 let req = match self.effort {
1220 Some(effort) => req.with_effort(effort),
1221 None => req,
1222 };
1223 let mut usage = Usage::default();
1224 let mut stream = match self.provider.stream(req).await {
1225 Ok(stream) => stream,
1226 Err(error) => return (Err(error), Some(usage)),
1227 };
1228 let mut summary = String::new();
1229 loop {
1230 tokio::select! {
1231 biased;
1232 _ = cancel.cancelled() => return (Ok(()), Some(usage)),
1233 chunk = stream.next() => {
1234 let Some(chunk) = chunk else { break };
1235 match chunk {
1236 Ok(Chunk::TextDelta(text)) => summary.push_str(&text),
1237 Ok(Chunk::Usage(call_usage)) => usage = call_usage,
1239 Ok(_) => {}
1240 Err(error) => return (Err(error), Some(usage)),
1241 }
1242 }
1243 }
1244 }
1245 if summary.trim().is_empty() {
1246 return (Ok(()), Some(usage));
1247 }
1248
1249 let mut new_msgs = vec![Message::user_text(format!(
1250 "[summary of earlier conversation]\n{}",
1251 summary.trim()
1252 ))];
1253 new_msgs.extend(recent.iter().cloned());
1254 let to = new_msgs.len();
1255 if let Err(error) = self.events.record_compaction(session_id, &new_msgs) {
1256 return (Err(error), Some(usage));
1257 }
1258
1259 let obs = flux_evidence::Observation::new(
1260 "context.compacted",
1261 flux_evidence::Phase::Turn,
1262 serde_json::json!({
1263 "from_messages": messages.len(),
1264 "to_messages": to,
1265 "approx_chars_before": total,
1266 }),
1267 );
1268 self.executor.observe(obs.clone());
1269 sink.observation(&obs);
1270 (Ok(()), Some(usage))
1271 }
1272}
1273
1274fn flush_tail(
1278 tail: &[flux_evidence::Observation],
1279 mut record: impl FnMut(&flux_evidence::Observation) -> Result<()>,
1280) -> usize {
1281 let mut written = 0;
1282 for obs in tail {
1283 if record(obs).is_err() {
1286 break;
1287 }
1288 written += 1;
1289 }
1290 written
1291}
1292
1293pub(crate) fn redact_observation(
1297 redactor: &flux_secret::Redactor,
1298 obs: &flux_evidence::Observation,
1299) -> flux_evidence::Observation {
1300 let mut out = obs.clone();
1301 redact_json_strings(redactor, &mut out.data);
1302 out
1303}
1304
1305fn redact_json_strings(redactor: &flux_secret::Redactor, value: &mut serde_json::Value) {
1307 match value {
1308 serde_json::Value::String(s) => *s = redactor.redact(s),
1309 serde_json::Value::Array(items) => {
1310 for item in items {
1311 redact_json_strings(redactor, item);
1312 }
1313 }
1314 serde_json::Value::Object(map) => {
1315 for v in map.values_mut() {
1316 redact_json_strings(redactor, v);
1317 }
1318 }
1319 _ => {}
1320 }
1321}
1322
1323fn has_tool_result(msg: &Message) -> bool {
1325 msg.content
1326 .iter()
1327 .any(|b| matches!(b, ContentBlock::ToolResult { .. }))
1328}
1329
1330#[derive(Debug, Clone, PartialEq, Eq)]
1332pub enum LoopSource {
1333 Builtin,
1335}
1336
1337pub(crate) fn surfaced_op_names(
1344 reg: &flux_runtime::ToolRegistry,
1345 groups: &[flux_evidence::ToolGroup],
1346 cwd: &std::path::Path,
1347 sticky: &std::sync::Mutex<std::collections::HashMap<String, std::collections::HashSet<String>>>,
1348 session_id: &str,
1349 ambient: &[String],
1350 user_input: &str,
1351) -> (std::collections::HashSet<String>, Option<SurfacedGroups>) {
1352 if groups.is_empty() {
1353 let advertised = reg
1354 .specs()
1355 .iter()
1356 .filter(|s| s.group.as_deref() != Some(flux_runtime::REFLECT_GROUP))
1357 .map(|s| s.name.clone())
1358 .collect();
1359 return (advertised, None);
1360 }
1361 let mut signals = flux_runtime::detect_signals(cwd);
1362 signals.extend(
1366 ambient
1367 .iter()
1368 .map(|s| flux_evidence::Observation::signal(s)),
1369 );
1370 signals.extend(flux_evidence::turn_intent_observations(groups, user_input));
1371 let active = flux_evidence::resolve_active_groups(groups, &signals);
1372 let accumulated = {
1379 let mut sessions = sticky.lock().unwrap_or_else(|e| e.into_inner());
1380 let session = sessions.entry(session_id.to_string()).or_default();
1381 session.extend(active);
1382 session.clone()
1383 };
1384 let advertised = flux_runtime::advertised_op_names(®.specs(), groups, &accumulated);
1385 let signal_names = signals
1388 .iter()
1389 .filter_map(|o| o.data.get("signal").and_then(serde_json::Value::as_str))
1390 .map(str::to_string)
1391 .collect();
1392 (
1393 advertised,
1394 Some(SurfacedGroups {
1395 active: accumulated,
1398 signals: signal_names,
1399 }),
1400 )
1401}
1402
1403pub(crate) struct SurfacedGroups {
1406 pub active: std::collections::HashSet<String>,
1407 pub signals: Vec<String>,
1408}
1409
1410pub fn builtin_agent_loop() -> &'static str {
1412 include_str!("../assets/agent-loop.flux")
1413}
1414
1415pub fn agent_loop_source(cwd: &std::path::Path) -> (LoopSource, String) {
1418 let _ = cwd;
1419 (LoopSource::Builtin, builtin_agent_loop().to_string())
1420}
1421
1422pub fn load_agent_loop(cwd: &std::path::Path) -> Result<DraftAst> {
1425 let (_, src) = agent_loop_source(cwd);
1426 flux_lang::parse::parse(&src)
1429 .map_err(|e| flux_core::Error::Other(format!("agent-loop.flux: invalid flow: {e}")))
1430}
1431
1432fn load_agent_loop_with_iterations(spec: AgentLoopSpec, max_iterations: usize) -> Result<DraftAst> {
1437 if max_iterations == 0 {
1438 return Err(flux_core::Error::Other(
1439 "max_iterations must be greater than zero".into(),
1440 ));
1441 }
1442 if max_iterations > MAX_AGENT_LOOP_ITERATIONS {
1443 return Err(flux_core::Error::Other(format!(
1444 "max_iterations {max_iterations} exceeds the maximum of \
1445 {MAX_AGENT_LOOP_ITERATIONS} (the built-in agent loop expands once per iteration)"
1446 )));
1447 }
1448 let max = max_iterations as u32;
1451 let builtin = matches!(spec, AgentLoopSpec::Builtin(BuiltinAgentLoop::Adaptive));
1452 let mut ast = match spec {
1453 AgentLoopSpec::Builtin(BuiltinAgentLoop::Adaptive) => {
1454 flux_lang::parse::parse(builtin_agent_loop()).map_err(|error| {
1455 flux_core::Error::Other(format!("built-in adaptive loop is invalid: {error}"))
1456 })?
1457 }
1458 AgentLoopSpec::Flux(ast) => ast,
1459 };
1460 for node in &mut ast.body {
1461 if let crate::ast::Node::Repeat {
1462 max: repeat_max,
1463 until: Some(until),
1464 ..
1465 } = node
1466 {
1467 if matches!(until.as_ref(), crate::ast::Node::Var { name } if name.0 == "done") {
1468 *repeat_max = max;
1469 }
1470 }
1471 }
1472 if builtin {
1473 ast = lift_builtin_repeat_awaits(ast)?;
1474 }
1475 Ok(ast)
1476}
1477
1478fn lift_builtin_repeat_awaits(mut ast: DraftAst) -> Result<DraftAst> {
1483 let mut lowered = Vec::new();
1484 let mut repeat_index = 0usize;
1485 for node in std::mem::take(&mut ast.body) {
1486 let crate::ast::Node::Repeat {
1487 max,
1488 until,
1489 body,
1490 collect,
1491 } = node
1492 else {
1493 lowered.push(node);
1494 continue;
1495 };
1496 if !body
1497 .iter()
1498 .any(|node| matches!(node, crate::ast::Node::Await { .. }))
1499 {
1500 lowered.push(crate::ast::Node::Repeat {
1501 max,
1502 until,
1503 body,
1504 collect,
1505 });
1506 continue;
1507 }
1508 if collect.is_some() {
1509 return Err(Error::Other(
1510 "the built-in repeatable-decision loop cannot collect iteration values".into(),
1511 ));
1512 }
1513 let until = until.ok_or_else(|| {
1514 Error::Other("the built-in repeatable-decision loop must have an `until` guard".into())
1515 })?;
1516 if !matches!(
1517 until.as_ref(),
1518 crate::ast::Node::Var { .. } | crate::ast::Node::Lit { .. }
1519 ) {
1520 return Err(Error::Other(
1521 "the built-in repeatable-decision loop requires a literal or variable `until` guard"
1522 .into(),
1523 ));
1524 }
1525 let active = crate::ast::SymbolName(format!("__adaptive_repeat_{repeat_index}_active"));
1526 lowered.push(crate::ast::Node::Bind {
1527 name: active.clone(),
1528 value: Box::new(crate::ast::Node::Lit {
1529 value: serde_json::Value::Bool(true),
1530 }),
1531 ty: None,
1532 effect: None,
1533 });
1534 for _ in 0..max {
1535 for statement in &body {
1536 match statement {
1537 crate::ast::Node::Await {
1538 binding,
1539 source,
1540 as_type,
1541 condition,
1542 } => {
1543 let condition = match condition.as_deref() {
1544 None => crate::ast::Node::Var {
1545 name: active.clone(),
1546 },
1547 Some(
1548 condition @ (crate::ast::Node::Var { .. }
1549 | crate::ast::Node::Lit { .. }),
1550 ) => crate::ast::Node::Expr {
1551 formula: "active && requested".into(),
1552 vars: [
1553 (
1554 "active".into(),
1555 Box::new(crate::ast::Node::Var {
1556 name: active.clone(),
1557 }),
1558 ),
1559 ("requested".into(), Box::new(condition.clone())),
1560 ]
1561 .into_iter()
1562 .collect(),
1563 },
1564 Some(_) => {
1565 return Err(Error::Other(
1566 "the built-in repeatable decision requires a literal or variable await condition"
1567 .into(),
1568 ));
1569 }
1570 };
1571 lowered.push(crate::ast::Node::Await {
1572 binding: binding.clone(),
1573 source: source.clone(),
1574 as_type: as_type.clone(),
1575 condition: Some(Box::new(condition)),
1576 });
1577 }
1578 statement => lowered.push(crate::ast::Node::When {
1579 cond: Box::new(crate::ast::Node::Var {
1580 name: active.clone(),
1581 }),
1582 then: vec![statement.clone()],
1583 otherwise: Vec::new(),
1584 }),
1585 }
1586 }
1587 lowered.push(crate::ast::Node::Bind {
1588 name: active.clone(),
1589 value: Box::new(crate::ast::Node::Expr {
1590 formula: "active && !stop".into(),
1591 vars: [
1592 (
1593 "active".into(),
1594 Box::new(crate::ast::Node::Var {
1595 name: active.clone(),
1596 }),
1597 ),
1598 ("stop".into(), Box::new(until.as_ref().clone())),
1599 ]
1600 .into_iter()
1601 .collect(),
1602 }),
1603 ty: None,
1604 effect: None,
1605 });
1606 }
1607 repeat_index += 1;
1608 }
1609 ast.body = lowered;
1610 Ok(ast)
1611}
1612
1613fn validate_agent_loop(
1614 ast: &DraftAst,
1615 tools: &flux_runtime::ToolRegistry,
1616 composites: Vec<flux_lang::program::CompositeOpDecl>,
1617) -> Result<()> {
1618 let catalog = OpRegistry::new(tools).with_owned_composites(composites);
1619 flux_lang::analyze::analyze_flow(ast, &catalog, &std::collections::HashSet::new()).map_err(
1620 |diagnostics| {
1621 Error::Other(format!(
1622 "agent loop failed validation: {}",
1623 diagnostics
1624 .into_iter()
1625 .map(|diagnostic| diagnostic.message)
1626 .collect::<Vec<_>>()
1627 .join("; ")
1628 ))
1629 },
1630 )
1631}
1632
1633const MACHINERY_OPS: &[&str] = &[
1637 "detect_intent",
1638 "explore",
1639 "approve_batch",
1640 "execute_batch",
1641 "present_results",
1642 "ai_segment",
1643 "observe",
1644 "evidence",
1645 "metrics",
1646 "grade",
1647];
1648
1649pub fn is_loop_machinery_op(name: &str) -> bool {
1652 MACHINERY_OPS.contains(&name)
1653}
1654
1655pub fn show_loop() -> bool {
1659 flux_system::env_truthy("FLUX_SHOW_LOOP")
1660}
1661
1662pub fn trace_loop() -> bool {
1667 flux_system::env_truthy("FLUX_TRACE_LOOP")
1668}
1669
1670fn drain_event(ev: crate::loop_host::SinkEvent, sink: &mut dyn AgentSink, reveal: bool) {
1674 use crate::loop_host::SinkEvent;
1675 let machinery = match &ev {
1676 SinkEvent::ToolCall(name, _)
1677 | SinkEvent::ToolTiming(name, _)
1678 | SinkEvent::ToolResult(name, _) => is_loop_machinery_op(name),
1679 _ => false,
1680 };
1681 if reveal || !machinery {
1682 ev.apply(sink);
1683 }
1684}
1685
1686struct NullSink;
1689impl AgentSink for NullSink {}
1690
1691pub fn model_error(e: &flux_core::Error) -> String {
1695 match e {
1696 flux_core::Error::Api { status, message } => {
1697 let detail = serde_json::from_str::<serde_json::Value>(message)
1698 .ok()
1699 .and_then(|v| {
1700 v.get("error")
1701 .and_then(|err| err.get("message"))
1702 .and_then(|m| m.as_str())
1703 .map(str::to_string)
1704 })
1705 .filter(|s| !s.trim().is_empty())
1706 .unwrap_or_else(|| message.clone());
1707 format!("the model provider returned an error (HTTP {status}): {detail}")
1708 }
1709 flux_core::Error::StreamDecode(detail) => format!(
1712 "the model provider's response broke mid-stream and could not be decoded: {detail}"
1713 ),
1714 _ => e.to_string(),
1715 }
1716}
1717
1718fn suspension_prompt(outcome: &FlowOutcome) -> String {
1723 let prompt = outcome.result.trim();
1724 if prompt.is_empty() {
1725 "(awaiting your input — reply to continue the flow)".to_string()
1726 } else {
1727 prompt.to_string()
1728 }
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use super::*;
1734
1735 use std::collections::VecDeque;
1736 use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
1737 use std::sync::Mutex;
1738
1739 use async_trait::async_trait;
1740 use flux_core::{StopReason, ToolResultContent};
1741 use flux_provider::ChunkStream;
1742 use flux_runtime::{
1743 AllowApprover, PermissionManager, Tool, ToolContext, ToolRegistry, ToolResult,
1744 };
1745 use flux_spec::{Effect, Idempotency, Risk, ToolSpec};
1746 use flux_system::{System, Workspace};
1747 use serde_json::{json, Value};
1748
1749 static TEST_ROOT: AtomicU64 = AtomicU64::new(0);
1750
1751 struct ScriptedProvider {
1752 responses: Mutex<VecDeque<Vec<Chunk>>>,
1753 requests: Arc<Mutex<Vec<Request>>>,
1754 }
1755
1756 #[async_trait]
1757 impl Provider for ScriptedProvider {
1758 fn name(&self) -> &str {
1759 "scripted"
1760 }
1761
1762 async fn stream(&self, request: Request) -> Result<ChunkStream> {
1763 self.requests.lock().unwrap().push(request);
1764 let chunks = self
1765 .responses
1766 .lock()
1767 .unwrap()
1768 .pop_front()
1769 .unwrap_or_default();
1770 Ok(Box::pin(futures::stream::iter(chunks.into_iter().map(Ok))))
1771 }
1772 }
1773
1774 struct PendingProvider;
1775
1776 #[async_trait]
1777 impl Provider for PendingProvider {
1778 fn name(&self) -> &str {
1779 "pending"
1780 }
1781
1782 async fn stream(&self, _request: Request) -> Result<ChunkStream> {
1783 Ok(Box::pin(futures::stream::pending()))
1784 }
1785 }
1786
1787 struct EchoTool;
1788
1789 #[async_trait]
1790 impl Tool for EchoTool {
1791 fn spec(&self) -> ToolSpec {
1792 ToolSpec::read_only(
1793 "echo",
1794 "Return the supplied text.",
1795 json!({
1796 "type": "object",
1797 "properties": {"text": {"type": "string"}},
1798 "required": ["text"],
1799 "additionalProperties": false
1800 }),
1801 )
1802 }
1803
1804 async fn execute(&self, _ctx: &ToolContext, input: Value) -> Result<ToolResult> {
1805 Ok(ToolResult::ok(
1806 input
1807 .get("text")
1808 .and_then(Value::as_str)
1809 .unwrap_or_default(),
1810 ))
1811 }
1812 }
1813
1814 struct CountingWriteTool(Arc<AtomicU64>);
1815
1816 #[async_trait]
1817 impl Tool for CountingWriteTool {
1818 fn spec(&self) -> ToolSpec {
1819 let mut spec = ToolSpec::read_only(
1820 "change",
1821 "Change the fixture exactly once.",
1822 json!({
1823 "type": "object",
1824 "properties": {
1825 "value": {"type": "string"},
1826 "fail": {"type": "boolean"}
1827 },
1828 "required": ["value"],
1829 "additionalProperties": false
1830 }),
1831 )
1832 .with_effects(vec![Effect::Write, Effect::Filesystem])
1833 .with_access(vec![flux_spec::AccessKind::Filesystem])
1834 .with_risk(Risk::Medium);
1835 spec.idempotency = Idempotency::NonIdempotent;
1836 spec
1837 }
1838
1839 fn permission_subjects(&self, _input: &Value) -> Vec<String> {
1840 vec!["fixture".into()]
1841 }
1842
1843 async fn execute(&self, _ctx: &ToolContext, input: Value) -> Result<ToolResult> {
1844 self.0.fetch_add(1, Ordering::SeqCst);
1845 if input.get("fail").and_then(Value::as_bool) == Some(true) {
1846 return Err(Error::Other("fixture change failed".into()));
1847 }
1848 Ok(ToolResult::ok("fixture changed"))
1849 }
1850 }
1851
1852 struct FileProbeTool(Arc<AtomicUsize>);
1853
1854 #[async_trait]
1855 impl Tool for FileProbeTool {
1856 fn spec(&self) -> ToolSpec {
1857 ToolSpec::read_only(
1858 "file_probe",
1859 "Read the lifecycle cache fixture.",
1860 json!({"type": "object", "additionalProperties": false}),
1861 )
1862 .with_access(vec![flux_spec::AccessKind::Filesystem])
1863 }
1864
1865 fn permission_subjects(&self, _input: &Value) -> Vec<String> {
1866 vec!["value.txt".into()]
1867 }
1868
1869 async fn execute(&self, ctx: &ToolContext, _input: Value) -> Result<ToolResult> {
1870 self.0.fetch_add(1, Ordering::SeqCst);
1871 Ok(ToolResult::ok(ctx.system.read_file("value.txt").await?))
1872 }
1873 }
1874
1875 struct NestedAuthoredTool;
1876
1877 #[async_trait]
1878 impl Tool for NestedAuthoredTool {
1879 fn spec(&self) -> ToolSpec {
1880 ToolSpec::read_only(
1881 "nested_authored",
1882 "Run one authored flow through the active loop host.",
1883 json!({"type": "object", "additionalProperties": false}),
1884 )
1885 }
1886
1887 async fn execute(&self, ctx: &ToolContext, _input: Value) -> Result<ToolResult> {
1888 let host = ctx
1889 .loop_host
1890 .as_ref()
1891 .ok_or_else(|| Error::Other("test loop host is missing".into()))?;
1892 let ast = DraftAst {
1893 body: vec![Node::Return {
1894 value: Box::new(Node::Lit {
1895 value: json!("nested result"),
1896 }),
1897 }],
1898 ..Default::default()
1899 };
1900 let outcome = host.run_authored_flow(serde_json::to_value(ast)?).await?;
1901 Ok(ToolResult::ok(
1902 outcome
1903 .get("result")
1904 .and_then(Value::as_str)
1905 .unwrap_or_default(),
1906 ))
1907 }
1908 }
1909
1910 struct TurnBarrier {
1911 entered: AtomicUsize,
1912 active: AtomicUsize,
1913 max_active: AtomicUsize,
1914 notify: tokio::sync::Notify,
1915 release: tokio::sync::Semaphore,
1916 }
1917
1918 impl Default for TurnBarrier {
1919 fn default() -> Self {
1920 Self {
1921 entered: AtomicUsize::new(0),
1922 active: AtomicUsize::new(0),
1923 max_active: AtomicUsize::new(0),
1924 notify: tokio::sync::Notify::new(),
1925 release: tokio::sync::Semaphore::new(0),
1926 }
1927 }
1928 }
1929
1930 impl TurnBarrier {
1931 async fn wait_for_entered(&self, expected: usize) {
1932 loop {
1933 let notified = self.notify.notified();
1934 if self.entered.load(Ordering::SeqCst) >= expected {
1935 return;
1936 }
1937 notified.await;
1938 }
1939 }
1940
1941 fn release(&self, permits: usize) {
1942 self.release.add_permits(permits);
1943 }
1944 }
1945
1946 struct ActiveCall<'a>(&'a AtomicUsize);
1947
1948 impl Drop for ActiveCall<'_> {
1949 fn drop(&mut self) {
1950 self.0.fetch_sub(1, Ordering::SeqCst);
1951 }
1952 }
1953
1954 struct BlockingTurnTool(Arc<TurnBarrier>);
1955
1956 #[async_trait]
1957 impl Tool for BlockingTurnTool {
1958 fn spec(&self) -> ToolSpec {
1959 ToolSpec::read_only(
1960 "blocking_turn",
1961 "Hold a turn open until the test releases it.",
1962 json!({"type": "object", "additionalProperties": false}),
1963 )
1964 }
1965
1966 async fn execute(&self, ctx: &ToolContext, _input: Value) -> Result<ToolResult> {
1967 let session_id = ctx.session_id().unwrap_or_default();
1968 let active = self.0.active.fetch_add(1, Ordering::SeqCst) + 1;
1969 self.0.max_active.fetch_max(active, Ordering::SeqCst);
1970 let _active = ActiveCall(&self.0.active);
1971 let call_number = self.0.entered.fetch_add(1, Ordering::SeqCst) + 1;
1972 ctx.evidence
1973 .lock()
1974 .unwrap()
1975 .record(flux_evidence::Observation::new(
1976 "test.turn_context",
1977 flux_evidence::Phase::Turn,
1978 json!({"session_id": session_id}),
1979 ));
1980 ctx.evidence
1981 .lock()
1982 .unwrap()
1983 .record(flux_evidence::Observation::new(
1984 "subagent.usage",
1985 flux_evidence::Phase::Turn,
1986 json!({
1987 "model": format!("child-{session_id}"),
1988 "usage": Usage {
1989 input_tokens: call_number as u64,
1990 output_tokens: (call_number * 10) as u64,
1991 ..Usage::default()
1992 }
1993 }),
1994 ));
1995 self.0.notify.notify_waiters();
1996 let permit = self
1997 .0
1998 .release
1999 .acquire()
2000 .await
2001 .map_err(|error| Error::Other(error.to_string()))?;
2002 permit.forget();
2003 Ok(ToolResult::ok(session_id))
2004 }
2005 }
2006
2007 struct IdentityBlockingTool(Arc<TurnBarrier>);
2008
2009 #[async_trait]
2010 impl Tool for IdentityBlockingTool {
2011 fn spec(&self) -> ToolSpec {
2012 ToolSpec::read_only(
2013 "identity_blocking",
2014 "Hold a turn open and report its immutable caller identity.",
2015 json!({"type": "object", "additionalProperties": false}),
2016 )
2017 }
2018
2019 async fn execute(&self, ctx: &ToolContext, _input: Value) -> Result<ToolResult> {
2020 let session_id = ctx.session_id().unwrap_or_default();
2021 let caller = ctx
2022 .turn_identity()
2023 .ok_or_else(|| Error::Other("test turn identity is missing".into()))?
2024 .caller()
2025 .principal
2026 .id
2027 .clone();
2028 let active = self.0.active.fetch_add(1, Ordering::SeqCst) + 1;
2029 self.0.max_active.fetch_max(active, Ordering::SeqCst);
2030 let _active = ActiveCall(&self.0.active);
2031 self.0.entered.fetch_add(1, Ordering::SeqCst);
2032 ctx.evidence
2033 .lock()
2034 .unwrap()
2035 .record(flux_evidence::Observation::new(
2036 "test.identity_context",
2037 flux_evidence::Phase::Turn,
2038 json!({"session_id": session_id.as_str(), "caller": caller.as_str()}),
2039 ));
2040 self.0.notify.notify_waiters();
2041 let permit = self
2042 .0
2043 .release
2044 .acquire()
2045 .await
2046 .map_err(|error| Error::Other(error.to_string()))?;
2047 permit.forget();
2048 Ok(ToolResult::ok(format!("{session_id}:{caller}")))
2049 }
2050 }
2051
2052 #[derive(Default)]
2053 struct CollectSink {
2054 text: String,
2055 tools: Vec<String>,
2056 observations: Vec<flux_evidence::Observation>,
2057 usages: Vec<Option<Usage>>,
2058 ended: usize,
2059 }
2060
2061 impl AgentSink for CollectSink {
2062 fn text_delta(&mut self, text: &str) {
2063 self.text.push_str(text);
2064 }
2065
2066 fn tool_call(&mut self, name: &str, _input: &Value) {
2067 self.tools.push(name.to_string());
2068 }
2069
2070 fn observation(&mut self, observation: &flux_evidence::Observation) {
2071 self.observations.push(observation.clone());
2072 }
2073
2074 fn turn_end(&mut self, usage: Option<Usage>) {
2075 self.usages.push(usage);
2076 self.ended += 1;
2077 }
2078 }
2079
2080 fn native_call(id: &str, name: &str, input: Value) -> Vec<Chunk> {
2081 vec![
2082 Chunk::Block(ContentBlock::ToolUse {
2083 id: id.into(),
2084 name: name.into(),
2085 input,
2086 }),
2087 Chunk::Done {
2088 stop_reason: Some(StopReason::ToolUse),
2089 },
2090 ]
2091 }
2092
2093 fn native_calls(calls: Vec<(&str, &str, Value)>) -> Vec<Chunk> {
2094 let mut chunks = calls
2095 .into_iter()
2096 .map(|(id, name, input)| {
2097 Chunk::Block(ContentBlock::ToolUse {
2098 id: id.into(),
2099 name: name.into(),
2100 input,
2101 })
2102 })
2103 .collect::<Vec<_>>();
2104 chunks.push(Chunk::Done {
2105 stop_reason: Some(StopReason::ToolUse),
2106 });
2107 chunks
2108 }
2109
2110 fn prose(text: &str) -> Vec<Chunk> {
2111 vec![
2112 Chunk::TextDelta(text.into()),
2113 Chunk::Done {
2114 stop_reason: Some(StopReason::EndTurn),
2115 },
2116 ]
2117 }
2118
2119 fn assemble_test_engine(
2120 provider: Arc<dyn Provider>,
2121 loop_spec: AgentLoopSpec,
2122 ) -> (Result<FlowEngine>, Arc<EventStore>) {
2123 let sequence = TEST_ROOT.fetch_add(1, Ordering::SeqCst);
2124 let root = std::env::temp_dir().join(format!(
2125 "flux-adaptive-engine-{}-{sequence}",
2126 std::process::id()
2127 ));
2128 std::fs::create_dir_all(&root).unwrap();
2129 let mut registry = ToolRegistry::new();
2130 registry.register(Arc::new(EchoTool));
2131 flux_tools::register_reflect(&mut registry);
2132 flux_tools::register_evidence(&mut registry);
2133 let executor = Executor::new(
2134 registry,
2135 PermissionManager::from_rules(&["echo".into()], &[]),
2136 Arc::new(AllowApprover),
2137 ToolContext::new(Arc::new(System::new(Workspace::new(&root).unwrap()))),
2138 );
2139 let events = Arc::new(EventStore::in_memory().unwrap());
2140 let flow = FlowStore::in_memory_with_events(events.clone()).unwrap();
2141 let engine = FlowEngine::assemble_with_loop(
2142 provider,
2143 executor,
2144 events.clone(),
2145 flow,
2146 "test-model".into(),
2147 "Use only observed evidence.".into(),
2148 2_048,
2149 5,
2150 Vec::new(),
2151 0,
2152 Vec::new(),
2153 root.clone(),
2154 loop_spec,
2155 );
2156 (engine, events)
2157 }
2158
2159 fn scripted_engine(
2160 responses: Vec<Vec<Chunk>>,
2161 loop_spec: AgentLoopSpec,
2162 ) -> (FlowEngine, Arc<EventStore>, Arc<Mutex<Vec<Request>>>) {
2163 let requests = Arc::new(Mutex::new(Vec::new()));
2164 let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider {
2165 responses: Mutex::new(responses.into()),
2166 requests: requests.clone(),
2167 });
2168 let (engine, events) = assemble_test_engine(provider, loop_spec);
2169 (engine.unwrap(), events, requests)
2170 }
2171
2172 type ScriptedWriteEngine = (
2173 FlowEngine,
2174 Arc<EventStore>,
2175 Arc<Mutex<Vec<Request>>>,
2176 Arc<AtomicU64>,
2177 );
2178
2179 fn scripted_write_engine(responses: Vec<Vec<Chunk>>) -> ScriptedWriteEngine {
2180 let requests = Arc::new(Mutex::new(Vec::new()));
2181 let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider {
2182 responses: Mutex::new(responses.into()),
2183 requests: requests.clone(),
2184 });
2185 let writes = Arc::new(AtomicU64::new(0));
2186 let mut registry = ToolRegistry::new();
2187 registry.register(Arc::new(CountingWriteTool(writes.clone())));
2188 flux_tools::register_reflect(&mut registry);
2189 flux_tools::register_evidence(&mut registry);
2190 let sequence = TEST_ROOT.fetch_add(1, Ordering::SeqCst);
2191 let root = std::env::temp_dir().join(format!(
2192 "flux-adaptive-write-{}-{sequence}",
2193 std::process::id()
2194 ));
2195 std::fs::create_dir_all(&root).unwrap();
2196 let executor = Executor::new(
2197 registry,
2198 PermissionManager::from_rules(&["change".into()], &[]),
2199 Arc::new(AllowApprover),
2200 ToolContext::new(Arc::new(System::new(Workspace::new(&root).unwrap()))),
2201 );
2202 let events = Arc::new(EventStore::in_memory().unwrap());
2203 let flow = FlowStore::in_memory_with_events(events.clone()).unwrap();
2204 let engine = FlowEngine::assemble_with_loop(
2205 provider,
2206 executor,
2207 events.clone(),
2208 flow,
2209 "test-model".into(),
2210 "Use only observed evidence.".into(),
2211 2_048,
2212 8,
2213 Vec::new(),
2214 0,
2215 Vec::new(),
2216 root,
2217 AgentLoopSpec::default(),
2218 )
2219 .unwrap();
2220 (engine, events, requests, writes)
2221 }
2222
2223 fn tool_engine(
2224 tool: Arc<dyn Tool>,
2225 agent_loop: DraftAst,
2226 ) -> (FlowEngine, Arc<EventStore>, std::path::PathBuf) {
2227 let sequence = TEST_ROOT.fetch_add(1, Ordering::SeqCst);
2228 let root = std::env::temp_dir().join(format!(
2229 "flux-turn-lifecycle-{}-{sequence}",
2230 std::process::id()
2231 ));
2232 std::fs::create_dir_all(&root).unwrap();
2233 let name = tool.spec().name;
2234 let mut registry = ToolRegistry::new();
2235 registry.register(tool);
2236 let executor = Executor::new(
2237 registry,
2238 PermissionManager::from_rules(std::slice::from_ref(&name), &[]),
2239 Arc::new(AllowApprover),
2240 ToolContext::new(Arc::new(System::new(Workspace::new(&root).unwrap()))),
2241 );
2242 let events = Arc::new(EventStore::in_memory().unwrap());
2243 let flow = FlowStore::in_memory_with_events(events.clone()).unwrap();
2244 let engine = FlowEngine::assemble_with_loop(
2245 Arc::new(flux_provider::NullProvider),
2246 executor,
2247 events.clone(),
2248 flow,
2249 "test-model".into(),
2250 "Test turn isolation.".into(),
2251 2_048,
2252 5,
2253 Vec::new(),
2254 0,
2255 Vec::new(),
2256 root.clone(),
2257 AgentLoopSpec::Flux(agent_loop),
2258 )
2259 .unwrap();
2260 (engine, events, root)
2261 }
2262
2263 fn call_node(op: &str) -> flux_lang::ast::Node {
2264 flux_lang::ast::Node::Call {
2265 op: op.into(),
2266 args: Vec::new(),
2267 }
2268 }
2269
2270 fn blocking_agent_loop() -> DraftAst {
2271 DraftAst {
2272 body: vec![flux_lang::ast::Node::Return {
2273 value: Box::new(call_node("blocking_turn")),
2274 }],
2275 ..Default::default()
2276 }
2277 }
2278
2279 fn identity_blocking_agent_loop() -> DraftAst {
2280 DraftAst {
2281 body: vec![flux_lang::ast::Node::Return {
2282 value: Box::new(call_node("identity_blocking")),
2283 }],
2284 ..Default::default()
2285 }
2286 }
2287
2288 fn idle_agent_loop() -> DraftAst {
2289 DraftAst {
2290 body: vec![flux_lang::ast::Node::Return {
2291 value: Box::new(flux_lang::ast::Node::Lit {
2292 value: json!("idle"),
2293 }),
2294 }],
2295 ..Default::default()
2296 }
2297 }
2298
2299 fn nested_authored_agent_loop() -> DraftAst {
2300 DraftAst {
2301 body: vec![Node::Return {
2302 value: Box::new(call_node("nested_authored")),
2303 }],
2304 ..Default::default()
2305 }
2306 }
2307
2308 fn resumable_read_flow() -> DraftAst {
2309 use flux_lang::ast::{Node, SymbolName};
2310 DraftAst {
2311 name: Some("cache_resume".into()),
2312 body: vec![
2313 Node::Bind {
2314 name: SymbolName("before".into()),
2315 value: Box::new(call_node("file_probe")),
2316 ty: None,
2317 effect: None,
2318 },
2319 Node::Await {
2320 binding: Some(SymbolName("reply".into())),
2321 source: "user_input".into(),
2322 as_type: None,
2323 condition: None,
2324 },
2325 Node::Bind {
2326 name: SymbolName("after".into()),
2327 value: Box::new(call_node("file_probe")),
2328 ty: None,
2329 effect: None,
2330 },
2331 Node::Return {
2332 value: Box::new(Node::Var {
2333 name: SymbolName("after".into()),
2334 }),
2335 },
2336 ],
2337 ..Default::default()
2338 }
2339 }
2340
2341 fn blocking_resume_flow() -> DraftAst {
2342 use flux_lang::ast::{Node, SymbolName};
2343 DraftAst {
2344 name: Some("cancel_resume".into()),
2345 body: vec![
2346 Node::Await {
2347 binding: Some(SymbolName("reply".into())),
2348 source: "user_input".into(),
2349 as_type: None,
2350 condition: None,
2351 },
2352 Node::Return {
2353 value: Box::new(call_node("blocking_turn")),
2354 },
2355 ],
2356 ..Default::default()
2357 }
2358 }
2359
2360 #[test]
2361 fn builtin_loop_is_authored_flux_without_the_retired_compiler_ops() {
2362 let source = builtin_agent_loop();
2363 let ast = flux_lang::parse::parse(source).expect("built-in loop parses");
2364 let formatted = flux_lang::format::format(&ast);
2365 flux_lang::parse::parse(&formatted).expect("built-in loop round-trips");
2366
2367 for retired in ["emit_plan", "run_plan", "staged_plan"] {
2368 assert!(!source.contains(retired), "found retired op `{retired}`");
2369 }
2370 for stage in [
2371 "detect_intent",
2372 "explore",
2373 "approve_batch",
2374 "execute_batch",
2375 "present_results",
2376 ] {
2377 assert!(source.contains(stage), "missing adaptive stage `{stage}`");
2378 }
2379 assert!(
2380 source.contains(&format!("repeat {DEFAULT_AGENT_LOOP_ITERATIONS}")),
2381 "the inspectable built-in source must show the default outer-loop bound \
2382 ({DEFAULT_AGENT_LOOP_ITERATIONS}); regenerate assets/agent-loop.flux if this fails"
2383 );
2384 }
2385
2386 #[test]
2387 fn outer_loop_iteration_bounds_reject_zero_and_values_above_the_practical_cap() {
2388 let zero = load_agent_loop_with_iterations(AgentLoopSpec::default(), 0)
2389 .unwrap_err()
2390 .to_string();
2391 assert!(zero.contains("must be greater than zero"), "{zero}");
2392
2393 load_agent_loop_with_iterations(AgentLoopSpec::default(), MAX_AGENT_LOOP_ITERATIONS)
2394 .expect("the practical maximum remains usable");
2395
2396 let above_max = load_agent_loop_with_iterations(
2397 AgentLoopSpec::default(),
2398 MAX_AGENT_LOOP_ITERATIONS + 1,
2399 );
2400 match above_max {
2401 Err(error) => {
2402 let error = error.to_string();
2403 assert!(
2404 error.contains(&format!("maximum of {MAX_AGENT_LOOP_ITERATIONS}")),
2405 "{error}"
2406 );
2407 }
2408 Ok(_) => panic!("a bound above the practical maximum must fail before AST expansion"),
2409 }
2410 }
2411
2412 #[tokio::test]
2413 async fn ai_segment_honors_an_authored_50_round_budget() {
2414 let mut responses = (0..49)
2415 .map(|round| {
2416 native_call(
2417 &format!("echo-{round}"),
2418 "echo",
2419 json!({"text": format!("evidence-{round}")}),
2420 )
2421 })
2422 .collect::<Vec<_>>();
2423 responses.push(prose("All 49 observations were gathered."));
2424 let loop_spec = AgentLoopSpec::Flux(
2425 flux_lang::parse::parse(
2426 r#"flow segment_test -> string
2427 $segment = ai_segment({ goal: "Gather every observation", tools: ["echo"], max_rounds: 50 })
2428 return $segment.result
2429"#,
2430 )
2431 .expect("test loop parses"),
2432 );
2433 let (engine, events, requests) = scripted_engine(responses, loop_spec);
2434 let session = events.create_session("scripted/test-model").unwrap();
2435 let mut sink = CollectSink::default();
2436
2437 engine
2438 .run_turn(&session, "Run the segment", &mut sink)
2439 .await
2440 .unwrap();
2441
2442 assert_eq!(sink.text, "All 49 observations were gathered.");
2443 assert_eq!(requests.lock().unwrap().len(), 50);
2444 }
2445
2446 #[tokio::test]
2447 async fn default_turn_routes_intent_then_uses_exact_native_tool_schema() {
2448 let (engine, events, requests) = scripted_engine(
2449 vec![
2450 native_call(
2451 "intent-1",
2452 "declare_intent",
2453 json!({
2454 "intent": "echo a grounded value",
2455 "capability_families": ["core"]
2456 }),
2457 ),
2458 native_call("echo-1", "echo", json!({"text": "evidence"})),
2459 prose("The observed value is evidence."),
2460 ],
2461 AgentLoopSpec::default(),
2462 );
2463 let session = events.create_session("scripted/test-model").unwrap();
2464 let mut sink = CollectSink::default();
2465
2466 engine
2467 .run_turn(&session, "Echo a grounded value", &mut sink)
2468 .await
2469 .unwrap();
2470
2471 assert_eq!(sink.text, "The observed value is evidence.");
2472 assert_eq!(sink.tools, vec!["echo"]);
2473 assert_eq!(sink.ended, 1);
2474 let requests = requests.lock().unwrap();
2475 assert_eq!(requests.len(), 3);
2476 assert_eq!(
2477 requests[0]
2478 .tools
2479 .iter()
2480 .map(|tool| tool.name.as_str())
2481 .collect::<Vec<_>>(),
2482 vec!["declare_intent"]
2483 );
2484 assert_eq!(
2485 requests[1]
2486 .tools
2487 .iter()
2488 .find(|tool| tool.name == "echo")
2489 .expect("selected native op")
2490 .input_schema,
2491 json!({
2492 "type": "object",
2493 "properties": {"text": {"type": "string"}},
2494 "required": ["text"],
2495 "additionalProperties": false
2496 })
2497 );
2498 assert!(requests
2499 .iter()
2500 .flat_map(|request| &request.tools)
2501 .all(|tool| tool.name != "emit_plan"));
2502 let feedback = requests[2]
2503 .messages
2504 .iter()
2505 .flat_map(|message| &message.content)
2506 .filter_map(|block| match block {
2507 ContentBlock::ToolResult { content, .. } => Some(content),
2508 _ => None,
2509 })
2510 .flatten()
2511 .filter_map(|content| match content {
2512 ToolResultContent::Text { text } => Some(text.as_str()),
2513 ToolResultContent::Image { .. } => None,
2514 })
2515 .collect::<Vec<_>>()
2516 .join("\n");
2517 assert!(feedback.contains("evidence"));
2518 assert_eq!(events.conversation(&session).unwrap().len(), 2);
2519 }
2520
2521 #[tokio::test]
2522 async fn authored_decision_suspends_and_resumes_the_same_native_ledger() {
2523 let (engine, events, requests) = scripted_engine(
2524 vec![
2525 native_call(
2526 "intent-1",
2527 "declare_intent",
2528 json!({
2529 "intent": "choose a fixture",
2530 "capability_families": []
2531 }),
2532 ),
2533 native_call(
2534 "decision-1",
2535 "request_decision",
2536 json!({
2537 "prompt": "Which fixture should I use?",
2538 "options": ["alpha", "beta"]
2539 }),
2540 ),
2541 prose("I will use alpha."),
2542 ],
2543 AgentLoopSpec::default(),
2544 );
2545 let session = events.create_session("scripted/test-model").unwrap();
2546 let mut sink = CollectSink::default();
2547
2548 engine
2549 .run_turn(&session, "Choose a fixture", &mut sink)
2550 .await
2551 .unwrap();
2552 assert!(sink.text.contains("Which fixture should I use?"));
2553 assert!(sink.text.contains("1. alpha"));
2554
2555 engine.run_turn(&session, "alpha", &mut sink).await.unwrap();
2556 assert!(sink.text.ends_with("I will use alpha."));
2557 assert_eq!(sink.ended, 2);
2558 assert_eq!(events.conversation(&session).unwrap().len(), 4);
2559 let requests = requests.lock().unwrap();
2560 assert_eq!(requests.len(), 3, "resume must not re-run intent detection");
2561 let resumed_context = requests[2]
2562 .messages
2563 .iter()
2564 .flat_map(|message| &message.content)
2565 .filter_map(|block| match block {
2566 ContentBlock::ToolResult { content, .. } => Some(content),
2567 _ => None,
2568 })
2569 .flatten()
2570 .filter_map(|content| match content {
2571 ToolResultContent::Text { text } => Some(text.as_str()),
2572 ToolResultContent::Image { .. } => None,
2573 })
2574 .collect::<Vec<_>>()
2575 .join("\n");
2576 assert!(resumed_context.contains("alpha"));
2577 }
2578
2579 #[tokio::test]
2580 async fn every_adaptive_decision_suspends_and_resumes_the_same_native_ledger() {
2581 let (engine, events, requests) = scripted_engine(
2582 vec![
2583 native_call(
2584 "intent-1",
2585 "declare_intent",
2586 json!({
2587 "intent": "choose two fixtures",
2588 "capability_families": []
2589 }),
2590 ),
2591 native_call(
2592 "decision-1",
2593 "request_decision",
2594 json!({
2595 "prompt": "Which primary fixture should I use?",
2596 "options": ["alpha", "beta"]
2597 }),
2598 ),
2599 native_call(
2600 "decision-2",
2601 "request_decision",
2602 json!({
2603 "prompt": "Which fallback fixture should I use?",
2604 "options": ["gamma", "delta"]
2605 }),
2606 ),
2607 prose("I will use alpha with gamma as the fallback."),
2608 ],
2609 AgentLoopSpec::default(),
2610 );
2611 let session = events.create_session("scripted/test-model").unwrap();
2612 let mut sink = CollectSink::default();
2613
2614 engine
2615 .run_turn(&session, "Choose two fixtures", &mut sink)
2616 .await
2617 .unwrap();
2618 assert!(sink.text.contains("Which primary fixture should I use?"));
2619
2620 engine.run_turn(&session, "alpha", &mut sink).await.unwrap();
2621 assert!(sink.text.contains("Which fallback fixture should I use?"));
2622
2623 engine.run_turn(&session, "gamma", &mut sink).await.unwrap();
2624 assert!(sink
2625 .text
2626 .ends_with("I will use alpha with gamma as the fallback."));
2627 assert_eq!(sink.ended, 3);
2628 assert_eq!(events.conversation(&session).unwrap().len(), 6);
2629 assert_eq!(
2630 requests.lock().unwrap().len(),
2631 4,
2632 "each resume must continue the native ledger without rerunning intent"
2633 );
2634 }
2635
2636 #[tokio::test]
2637 async fn decision_after_execution_resumes_without_replaying_the_completed_action() {
2638 let responses = vec![
2639 native_call(
2640 "intent-1",
2641 "declare_intent",
2642 json!({
2643 "intent": "change a fixture and choose the follow-up",
2644 "capability_families": ["workspace.write"]
2645 }),
2646 ),
2647 native_call("change-1", "change", json!({"value": "updated"})),
2648 native_call(
2649 "finalize-1",
2650 "finalize_plan",
2651 json!({"instructions": "Report the completed change after resolving the follow-up."}),
2652 ),
2653 native_call(
2654 "decision-after-execution",
2655 "request_decision",
2656 json!({
2657 "prompt": "Which follow-up should I report?",
2658 "options": ["summary", "details"]
2659 }),
2660 ),
2661 prose("The fixture changed once; here is the summary."),
2662 ];
2663 let (engine, events, requests, writes) = scripted_write_engine(responses);
2664 let session = events.create_session("scripted/test-model").unwrap();
2665 let mut sink = CollectSink::default();
2666
2667 engine
2668 .run_turn(&session, "Change the fixture", &mut sink)
2669 .await
2670 .unwrap();
2671 assert_eq!(
2672 writes.load(Ordering::SeqCst),
2673 1,
2674 "sink text={:?}, tools={:?}, conversation={:?}, requests={}",
2675 sink.text,
2676 sink.tools,
2677 events.conversation(&session).unwrap(),
2678 requests.lock().unwrap().len(),
2679 );
2680 assert!(sink.text.contains("Which follow-up should I report?"));
2681 assert!(
2682 sink.text.contains("1. summary"),
2683 "sink text={:?}",
2684 sink.text
2685 );
2686 let approved = sink
2687 .observations
2688 .iter()
2689 .find(|observation| observation.kind == "approval.approved")
2690 .expect("approval outcome is observable");
2691 assert!(approved.data["wait_us"].is_number());
2692 let executed = sink
2693 .observations
2694 .iter()
2695 .find(|observation| observation.kind == "action_batch.executed")
2696 .expect("batch execution is observable");
2697 assert!(executed.data["duration_us"].is_number());
2698 assert!(sink
2699 .observations
2700 .iter()
2701 .any(|observation| observation.kind == "model.call"
2702 && observation.data["stage"] == "explore"));
2703
2704 engine
2705 .run_turn(&session, "summary", &mut sink)
2706 .await
2707 .unwrap();
2708 assert_eq!(
2709 writes.load(Ordering::SeqCst),
2710 1,
2711 "resuming a post-execution decision must not replay the consumed batch"
2712 );
2713 assert!(sink
2714 .text
2715 .ends_with("The fixture changed once; here is the summary."));
2716 assert_eq!(
2717 requests.lock().unwrap().len(),
2718 5,
2719 "resume must continue after the execution report without rerunning intent or action capture"
2720 );
2721 }
2722
2723 #[tokio::test]
2724 async fn partial_failure_skips_later_actions_and_is_not_replayed_after_decision_resume() {
2725 let responses = vec![
2726 native_call(
2727 "intent-1",
2728 "declare_intent",
2729 json!({
2730 "intent": "attempt two fixture changes and choose the report",
2731 "capability_families": ["workspace.write"]
2732 }),
2733 ),
2734 native_calls(vec![
2735 (
2736 "change-fails",
2737 "change",
2738 json!({"value": "first", "fail": true}),
2739 ),
2740 ("change-skipped", "change", json!({"value": "second"})),
2741 ]),
2742 native_call(
2743 "finalize-1",
2744 "finalize_plan",
2745 json!({"instructions": "Report the partial failure after resolving presentation."}),
2746 ),
2747 native_call(
2748 "decision-after-failure",
2749 "request_decision",
2750 json!({
2751 "prompt": "How should I present the partial failure?",
2752 "options": ["briefly", "with details"]
2753 }),
2754 ),
2755 prose("The first change failed, so the second was skipped."),
2756 ];
2757 let (engine, events, requests, writes) = scripted_write_engine(responses);
2758 let session = events.create_session("scripted/test-model").unwrap();
2759 let mut sink = CollectSink::default();
2760
2761 engine
2762 .run_turn(&session, "Attempt both fixture changes", &mut sink)
2763 .await
2764 .unwrap();
2765 assert_eq!(
2766 writes.load(Ordering::SeqCst),
2767 1,
2768 "the second action must be skipped after the first action fails"
2769 );
2770 assert!(sink.text.contains("1. briefly"));
2771
2772 engine
2773 .run_turn(&session, "briefly", &mut sink)
2774 .await
2775 .unwrap();
2776 assert_eq!(
2777 writes.load(Ordering::SeqCst),
2778 1,
2779 "resuming after a partial failure must not replay either the failed or skipped action"
2780 );
2781 assert!(sink
2782 .text
2783 .ends_with("The first change failed, so the second was skipped."));
2784 assert_eq!(requests.lock().unwrap().len(), 5);
2785 }
2786
2787 #[test]
2788 fn surfaced_groups_do_not_leak_between_sessions_on_a_shared_engine() {
2789 let mut registry = ToolRegistry::new();
2790 registry.register(Arc::new(EchoTool));
2791 let groups = vec![flux_evidence::ToolGroup {
2792 name: "plugin.slack".into(),
2793 description: "Company chat operations.".into(),
2794 tools: vec!["echo".into()],
2795 surface_when: vec![flux_evidence::SignalMatch {
2796 kind: flux_evidence::KIND_TURN_INTENT.into(),
2797 signal: Some("slack".into()),
2798 }],
2799 }];
2800 let root = std::env::temp_dir();
2801 let sticky = std::sync::Mutex::new(std::collections::HashMap::new());
2802
2803 let (first, _) = surfaced_op_names(
2804 ®istry,
2805 &groups,
2806 &root,
2807 &sticky,
2808 "session-a",
2809 &[],
2810 "use slack",
2811 );
2812 assert!(first.contains("echo"));
2813
2814 let (second, _) = surfaced_op_names(
2815 ®istry,
2816 &groups,
2817 &root,
2818 &sticky,
2819 "session-b",
2820 &[],
2821 "say hello",
2822 );
2823 assert!(
2824 !second.contains("echo"),
2825 "a different session must not inherit another session's surfaced integration"
2826 );
2827 }
2828
2829 #[tokio::test]
2830 async fn explicit_authored_loop_runs_without_a_model_call() {
2831 let loop_spec = AgentLoopSpec::parse("flow custom -> string\n return \"custom loop\"")
2832 .expect("custom loop parses");
2833 let (engine, events, requests) = scripted_engine(Vec::new(), loop_spec);
2834 let session = events.create_session("scripted/test-model").unwrap();
2835 let mut sink = CollectSink::default();
2836
2837 engine.run_turn(&session, "hello", &mut sink).await.unwrap();
2838
2839 assert_eq!(sink.text, "custom loop");
2840 assert!(requests.lock().unwrap().is_empty());
2841 assert_eq!(events.conversation(&session).unwrap().len(), 2);
2842 }
2843
2844 #[tokio::test]
2845 async fn resumed_turn_starts_a_fresh_deterministic_read_cache_generation() {
2846 let calls = Arc::new(AtomicUsize::new(0));
2847 let (engine, events, root) =
2848 tool_engine(Arc::new(FileProbeTool(calls.clone())), idle_agent_loop());
2849 std::fs::write(root.join("value.txt"), "before").unwrap();
2850 let session = events.create_session("test-model").unwrap();
2851 let flow = resumable_read_flow();
2852 let mut started = CollectSink::default();
2853
2854 engine
2855 .start_flow_turn(&session, &flow, &mut started)
2856 .await
2857 .unwrap();
2858 assert!(engine.flow.has_suspension(&session).unwrap());
2859 assert_eq!(calls.load(Ordering::SeqCst), 1);
2860
2861 std::fs::write(root.join("value.txt"), "after").unwrap();
2862 let mut resumed = CollectSink::default();
2863 engine
2864 .run_turn(&session, "continue", &mut resumed)
2865 .await
2866 .unwrap();
2867
2868 assert_eq!(resumed.text, "after");
2869 assert_eq!(
2870 calls.load(Ordering::SeqCst),
2871 2,
2872 "the post-await read must execute in the resumed turn, not reuse the prior turn cache"
2873 );
2874 }
2875
2876 #[tokio::test]
2877 async fn pre_cancelled_resume_is_terminal_and_keeps_its_checkpoint() {
2878 let barrier = Arc::new(TurnBarrier::default());
2879 let (engine, events, _root) = tool_engine(
2880 Arc::new(BlockingTurnTool(barrier.clone())),
2881 idle_agent_loop(),
2882 );
2883 let session = events.create_session("test-model").unwrap();
2884 let mut started = CollectSink::default();
2885 engine
2886 .start_flow_turn(&session, &blocking_resume_flow(), &mut started)
2887 .await
2888 .unwrap();
2889
2890 let cancel = CancellationToken::new();
2891 cancel.cancel();
2892 let mut resumed = CollectSink::default();
2893 tokio::time::timeout(
2894 std::time::Duration::from_millis(250),
2895 engine.run_turn_cancellable(&session, "answer", &mut resumed, &cancel),
2896 )
2897 .await
2898 .expect("a pre-cancelled resume must return promptly")
2899 .unwrap();
2900
2901 assert_eq!(barrier.entered.load(Ordering::SeqCst), 0);
2902 assert!(engine.flow.has_suspension(&session).unwrap());
2903 assert_eq!(resumed.text, "(turn cancelled)");
2904 assert_eq!(resumed.ended, 1);
2905 let conversation = events.conversation(&session).unwrap();
2906 assert_eq!(conversation.len(), 3);
2907 assert_eq!(conversation[1].text(), "answer");
2908 assert_eq!(conversation[2].text(), "(turn cancelled)");
2909 assert_eq!(
2910 events.turns(&session).unwrap().last().unwrap().outcome,
2911 "cancelled"
2912 );
2913 }
2914
2915 #[tokio::test]
2916 async fn cancelling_an_in_flight_resume_keeps_its_checkpoint() {
2917 let barrier = Arc::new(TurnBarrier::default());
2918 let (engine, events, _root) = tool_engine(
2919 Arc::new(BlockingTurnTool(barrier.clone())),
2920 idle_agent_loop(),
2921 );
2922 let session = events.create_session("test-model").unwrap();
2923 let mut started = CollectSink::default();
2924 engine
2925 .start_flow_turn(&session, &blocking_resume_flow(), &mut started)
2926 .await
2927 .unwrap();
2928
2929 let cancel = CancellationToken::new();
2930 let mut resumed = CollectSink::default();
2931 let run = engine.run_turn_cancellable(&session, "answer", &mut resumed, &cancel);
2932 let controller = async {
2933 barrier.wait_for_entered(1).await;
2934 cancel.cancel();
2935 };
2936 let result = tokio::time::timeout(std::time::Duration::from_secs(1), async {
2937 let (result, ()) = tokio::join!(run, controller);
2938 result
2939 })
2940 .await
2941 .expect("an in-flight resumed operation must stop on cancellation");
2942 result.unwrap();
2943
2944 assert_eq!(barrier.active.load(Ordering::SeqCst), 0);
2945 assert!(engine.flow.has_suspension(&session).unwrap());
2946 assert_eq!(resumed.text, "(turn cancelled)");
2947 assert_eq!(resumed.ended, 1);
2948 let conversation = events.conversation(&session).unwrap();
2949 assert_eq!(conversation.len(), 3);
2950 assert_eq!(conversation[1].text(), "answer");
2951 assert_eq!(conversation[2].text(), "(turn cancelled)");
2952 assert_eq!(
2953 events.turns(&session).unwrap().last().unwrap().outcome,
2954 "cancelled"
2955 );
2956 }
2957
2958 fn persisted_turn_contexts(events: &EventStore, session_id: &str) -> Vec<String> {
2959 events
2960 .observations(session_id)
2961 .unwrap()
2962 .into_iter()
2963 .filter(|observation| observation.kind == "test.turn_context")
2964 .filter_map(|observation| observation.data["session_id"].as_str().map(str::to_string))
2965 .collect()
2966 }
2967
2968 fn persisted_callers(events: &EventStore, session_id: &str, kind: &str) -> Vec<String> {
2969 events
2970 .observations(session_id)
2971 .unwrap()
2972 .into_iter()
2973 .filter(|observation| observation.kind == kind)
2974 .filter_map(|observation| observation.data["caller"].as_str().map(str::to_string))
2975 .collect()
2976 }
2977
2978 #[tokio::test]
2979 async fn one_raw_engine_serializes_turns_without_cross_wiring_sinks_or_audit() {
2980 let barrier = Arc::new(TurnBarrier::default());
2981 let (mut engine, events, _root) = tool_engine(
2982 Arc::new(BlockingTurnTool(barrier.clone())),
2983 blocking_agent_loop(),
2984 );
2985 engine.groups = vec![
2986 flux_evidence::ToolGroup {
2987 name: "turn.first".into(),
2988 description: "First-turn-only test catalog.".into(),
2989 tools: vec!["blocking_turn".into()],
2990 surface_when: vec![flux_evidence::SignalMatch {
2991 kind: flux_evidence::KIND_TURN_INTENT.into(),
2992 signal: Some("first".into()),
2993 }],
2994 },
2995 flux_evidence::ToolGroup {
2996 name: "turn.second".into(),
2997 description: "Second-turn-only test catalog.".into(),
2998 tools: vec!["second_only".into()],
2999 surface_when: vec![flux_evidence::SignalMatch {
3000 kind: flux_evidence::KIND_TURN_INTENT.into(),
3001 signal: Some("second".into()),
3002 }],
3003 },
3004 ];
3005 let engine = Arc::new(engine);
3006 let authority_before = engine.executor.approval_context();
3007 let first_session = events.create_session("test-model").unwrap();
3008 let second_session = events.create_session("test-model").unwrap();
3009
3010 let first = tokio::spawn({
3011 let engine = engine.clone();
3012 let session = first_session.clone();
3013 async move {
3014 let mut sink = CollectSink::default();
3015 let result = engine.run_turn(&session, "first", &mut sink).await;
3016 (result, sink)
3017 }
3018 });
3019 barrier.wait_for_entered(1).await;
3020 let second = tokio::spawn({
3021 let engine = engine.clone();
3022 let session = second_session.clone();
3023 async move {
3024 let mut sink = CollectSink::default();
3025 let result = engine.run_turn(&session, "second", &mut sink).await;
3026 (result, sink)
3027 }
3028 });
3029
3030 assert!(
3031 tokio::time::timeout(
3032 std::time::Duration::from_millis(100),
3033 barrier.wait_for_entered(2)
3034 )
3035 .await
3036 .is_err(),
3037 "the second call must wait at the engine boundary"
3038 );
3039 barrier.release(1);
3040 let (first_result, first_sink) = first.await.unwrap();
3041 first_result.unwrap();
3042 barrier.wait_for_entered(2).await;
3043 barrier.release(1);
3044 let (second_result, second_sink) = second.await.unwrap();
3045 second_result.unwrap();
3046
3047 assert_eq!(barrier.max_active.load(Ordering::SeqCst), 1);
3048 assert_eq!(first_sink.text, first_session);
3049 assert_eq!(second_sink.text, second_session);
3050 assert_eq!(first_sink.tools, ["blocking_turn"]);
3051 assert_eq!(second_sink.tools, ["blocking_turn"]);
3052 assert_eq!(first_sink.usages.len(), 1);
3053 assert_eq!(second_sink.usages.len(), 1);
3054 assert_eq!(first_sink.usages[0].as_ref().unwrap().input_tokens, 1);
3055 assert_eq!(first_sink.usages[0].as_ref().unwrap().output_tokens, 10);
3056 assert_eq!(second_sink.usages[0].as_ref().unwrap().input_tokens, 2);
3057 assert_eq!(second_sink.usages[0].as_ref().unwrap().output_tokens, 20);
3058 assert_eq!(first_sink.ended, 1);
3059 assert_eq!(second_sink.ended, 1);
3060 let active_groups = |sink: &CollectSink| {
3061 sink.observations
3062 .iter()
3063 .find(|observation| observation.kind == "groups.active")
3064 .and_then(|observation| observation.data["groups"].as_array())
3065 .cloned()
3066 .unwrap_or_default()
3067 };
3068 assert_eq!(active_groups(&first_sink), vec![json!("turn.first")]);
3069 assert_eq!(active_groups(&second_sink), vec![json!("turn.second")]);
3070 let (first_advertised, _) = surfaced_op_names(
3071 engine.executor.registry(),
3072 &engine.groups,
3073 &engine.cwd,
3074 &engine.sticky_groups,
3075 &first_session,
3076 &[],
3077 "",
3078 );
3079 let (second_advertised, _) = surfaced_op_names(
3080 engine.executor.registry(),
3081 &engine.groups,
3082 &engine.cwd,
3083 &engine.sticky_groups,
3084 &second_session,
3085 &[],
3086 "",
3087 );
3088 assert!(first_advertised.contains("blocking_turn"));
3089 assert!(!second_advertised.contains("blocking_turn"));
3090 assert_eq!(engine.executor.approval_context(), authority_before);
3091 let authority: Value = serde_json::from_str(&authority_before).unwrap();
3092 assert_eq!(authority["caller"]["principal"]["id"], "local");
3093 assert_eq!(
3094 events
3095 .conversation(&first_session)
3096 .unwrap()
3097 .iter()
3098 .map(Message::text)
3099 .collect::<Vec<_>>(),
3100 vec!["first", first_session.as_str()]
3101 );
3102 assert_eq!(
3103 events
3104 .conversation(&second_session)
3105 .unwrap()
3106 .iter()
3107 .map(Message::text)
3108 .collect::<Vec<_>>(),
3109 vec!["second", second_session.as_str()]
3110 );
3111 assert_eq!(
3112 persisted_turn_contexts(&events, &first_session),
3113 vec![first_session]
3114 );
3115 assert_eq!(
3116 persisted_turn_contexts(&events, &second_session),
3117 vec![second_session]
3118 );
3119 }
3120
3121 #[tokio::test]
3122 async fn concurrent_raw_turns_keep_distinct_lexical_identities_in_sink_dispatch_and_audit() {
3123 let barrier = Arc::new(TurnBarrier::default());
3124 let (engine, events, _root) = tool_engine(
3125 Arc::new(IdentityBlockingTool(barrier.clone())),
3126 identity_blocking_agent_loop(),
3127 );
3128 let identity = |id: &str| {
3129 let (mut caller, trust) = engine.executor.identity().get();
3130 caller.principal.id = id.to_string();
3131 caller.principal.name = id.to_string();
3132 caller.source = "raw-engine-test".into();
3133 TurnIdentity::new(caller, trust)
3134 };
3135 let alice = identity("alice");
3136 let bob = identity("bob");
3137 let engine = Arc::new(engine);
3138 let first_session = events.create_session("test-model").unwrap();
3139 let second_session = events.create_session("test-model").unwrap();
3140
3141 let first = tokio::spawn({
3142 let engine = engine.clone();
3143 let session = first_session.clone();
3144 async move {
3145 let mut sink = CollectSink::default();
3146 let result = engine
3147 .run_turn_as(&session, "first", &mut sink, alice)
3148 .await;
3149 (result, sink)
3150 }
3151 });
3152 barrier.wait_for_entered(1).await;
3153 let second = tokio::spawn({
3154 let engine = engine.clone();
3155 let session = second_session.clone();
3156 async move {
3157 let mut sink = CollectSink::default();
3158 let result = engine.run_turn_as(&session, "second", &mut sink, bob).await;
3159 (result, sink)
3160 }
3161 });
3162
3163 assert!(
3164 tokio::time::timeout(
3165 std::time::Duration::from_millis(100),
3166 barrier.wait_for_entered(2),
3167 )
3168 .await
3169 .is_err(),
3170 "bob's turn must wait without retargeting alice's active context"
3171 );
3172 barrier.release(1);
3173 let (first_result, first_sink) = first.await.unwrap();
3174 first_result.unwrap();
3175 barrier.wait_for_entered(2).await;
3176 barrier.release(1);
3177 let (second_result, second_sink) = second.await.unwrap();
3178 second_result.unwrap();
3179
3180 assert_eq!(barrier.max_active.load(Ordering::SeqCst), 1);
3181 assert_eq!(first_sink.text, format!("{first_session}:alice"));
3182 assert_eq!(second_sink.text, format!("{second_session}:bob"));
3183 assert_eq!(first_sink.tools, ["identity_blocking"]);
3184 assert_eq!(second_sink.tools, ["identity_blocking"]);
3185 assert_eq!(
3186 persisted_callers(&events, &first_session, "turn.identity"),
3187 ["alice"]
3188 );
3189 assert_eq!(
3190 persisted_callers(&events, &second_session, "turn.identity"),
3191 ["bob"]
3192 );
3193 assert_eq!(
3194 persisted_callers(&events, &first_session, "tool_call"),
3195 ["alice"]
3196 );
3197 assert_eq!(
3198 persisted_callers(&events, &second_session, "tool_call"),
3199 ["bob"]
3200 );
3201 assert_eq!(
3202 persisted_callers(&events, &first_session, "test.identity_context"),
3203 ["alice"]
3204 );
3205 assert_eq!(
3206 persisted_callers(&events, &second_session, "test.identity_context"),
3207 ["bob"]
3208 );
3209 assert_eq!(
3210 engine.executor.identity().get().0.principal.id,
3211 "local",
3212 "explicit turns must not mutate the executor's assembly-time identity"
3213 );
3214 }
3215
3216 #[tokio::test]
3217 async fn nested_authored_flow_reuses_the_active_lifecycle_without_relocking() {
3218 let (engine, events, _root) =
3219 tool_engine(Arc::new(NestedAuthoredTool), nested_authored_agent_loop());
3220 let session = events.create_session("test-model").unwrap();
3221 let mut sink = CollectSink::default();
3222
3223 tokio::time::timeout(
3224 std::time::Duration::from_secs(1),
3225 engine.run_turn(&session, "run nested", &mut sink),
3226 )
3227 .await
3228 .expect("a nested authored flow must not recursively acquire the engine turn gate")
3229 .unwrap();
3230
3231 assert_eq!(sink.text, "nested result");
3232 assert_eq!(sink.ended, 1);
3233 }
3234
3235 #[tokio::test]
3236 async fn distinct_raw_engines_can_overlap() {
3237 let barrier = Arc::new(TurnBarrier::default());
3238 let (first_engine, first_events, _root) = tool_engine(
3239 Arc::new(BlockingTurnTool(barrier.clone())),
3240 blocking_agent_loop(),
3241 );
3242 let (second_engine, second_events, _root) = tool_engine(
3243 Arc::new(BlockingTurnTool(barrier.clone())),
3244 blocking_agent_loop(),
3245 );
3246 let first_engine = Arc::new(first_engine);
3247 let second_engine = Arc::new(second_engine);
3248 let first_session = first_events.create_session("test-model").unwrap();
3249 let second_session = second_events.create_session("test-model").unwrap();
3250
3251 let first = tokio::spawn({
3252 let engine = first_engine.clone();
3253 let session = first_session.clone();
3254 async move {
3255 let mut sink = CollectSink::default();
3256 let result = engine.run_turn(&session, "first", &mut sink).await;
3257 (result, sink)
3258 }
3259 });
3260 let second = tokio::spawn({
3261 let engine = second_engine.clone();
3262 let session = second_session.clone();
3263 async move {
3264 let mut sink = CollectSink::default();
3265 let result = engine.run_turn(&session, "second", &mut sink).await;
3266 (result, sink)
3267 }
3268 });
3269
3270 tokio::time::timeout(
3271 std::time::Duration::from_secs(1),
3272 barrier.wait_for_entered(2),
3273 )
3274 .await
3275 .expect("independent engines must reach their operations concurrently");
3276 assert_eq!(barrier.max_active.load(Ordering::SeqCst), 2);
3277 barrier.release(2);
3278 let (first_result, first_sink) = first.await.unwrap();
3279 let (second_result, second_sink) = second.await.unwrap();
3280 first_result.unwrap();
3281 second_result.unwrap();
3282 assert_eq!(first_sink.text, first_session);
3283 assert_eq!(second_sink.text, second_session);
3284 }
3285
3286 #[test]
3287 fn explicit_authored_loop_is_validated_against_the_live_catalog() {
3288 let loop_spec = AgentLoopSpec::parse(
3289 "flow invalid -> string\n $value = missing_operation()\n return $value",
3290 )
3291 .expect("source parses before semantic validation");
3292 let provider: Arc<dyn Provider> = Arc::new(ScriptedProvider {
3293 responses: Mutex::new(VecDeque::new()),
3294 requests: Arc::new(Mutex::new(Vec::new())),
3295 });
3296 let (engine, _) = assemble_test_engine(provider, loop_spec);
3297 let error = engine.err().expect("unknown op must fail assembly");
3298 assert!(error.to_string().contains("missing_operation"));
3299 }
3300
3301 #[tokio::test]
3302 async fn cancellation_keeps_a_valid_user_assistant_session_shape() {
3303 let provider: Arc<dyn Provider> = Arc::new(PendingProvider);
3304 let (engine, events) = assemble_test_engine(provider, AgentLoopSpec::default());
3305 let engine = engine.unwrap();
3306 let session = events.create_session("pending/test-model").unwrap();
3307 let mut sink = CollectSink::default();
3308 let cancel = CancellationToken::new();
3309 cancel.cancel();
3310
3311 engine
3312 .run_turn_cancellable(&session, "wait forever", &mut sink, &cancel)
3313 .await
3314 .unwrap();
3315
3316 let conversation = events.conversation(&session).unwrap();
3317 assert_eq!(conversation.len(), 2);
3318 assert_eq!(conversation[0].text(), "wait forever");
3319 assert_eq!(conversation[1].text(), "(turn cancelled)");
3320 assert_eq!(sink.ended, 1);
3321 }
3322}