Skip to main content

symbi_runtime/reasoning/
phases.rs

1//! Typestate-enforced phase transitions
2//!
3//! Uses zero-sized phase markers with `PhantomData` to make invalid
4//! phase transitions a compile-time error. The loop driver transitions
5//! through Reasoning → PolicyCheck → ToolDispatching → Observing,
6//! and each transition consumes `self` to produce the next phase.
7//!
8//! This means it is structurally impossible to:
9//! - Skip the policy check
10//! - Dispatch tools without reasoning first
11//! - Observe results without dispatching
12//!
13//! Zero runtime cost: PhantomData is zero-sized.
14
15use std::marker::PhantomData;
16
17use crate::reasoning::circuit_breaker::CircuitBreakerRegistry;
18use crate::reasoning::context_manager::ContextManager;
19use crate::reasoning::executor::ActionExecutor;
20use crate::reasoning::inference::InferenceProvider;
21use crate::reasoning::loop_types::*;
22use crate::reasoning::policy_bridge::ReasoningPolicyGate;
23
24// ── Phase markers (zero-sized types) ────────────────────────────────
25
26/// The reasoning phase: LLM produces proposed actions.
27pub struct Reasoning;
28/// The policy check phase: every action is evaluated by the gate.
29pub struct PolicyCheck;
30/// The tool dispatching phase: approved actions are executed.
31pub struct ToolDispatching;
32/// The observation phase: results are collected for the next iteration.
33pub struct Observing;
34
35/// Marker trait for valid phases.
36pub trait AgentPhase {}
37impl AgentPhase for Reasoning {}
38impl AgentPhase for PolicyCheck {}
39impl AgentPhase for ToolDispatching {}
40impl AgentPhase for Observing {}
41
42// ── Phase data carriers ─────────────────────────────────────────────
43
44/// Data produced by the reasoning phase, consumed by policy check.
45pub struct ReasoningOutput {
46    /// Actions proposed by the LLM.
47    pub proposed_actions: Vec<ProposedAction>,
48}
49
50/// Data produced by the policy check phase, consumed by tool dispatch.
51pub struct PolicyOutput {
52    /// Actions approved by the policy gate.
53    pub approved_actions: Vec<ProposedAction>,
54    /// Actions denied, with their denial reasons.
55    pub denied_reasons: Vec<(ProposedAction, String)>,
56    /// Whether any Respond or Terminate action was approved.
57    pub has_terminal_action: bool,
58    /// Terminal output content if the loop should end.
59    pub terminal_output: Option<String>,
60}
61
62/// Data produced by tool dispatch, consumed by observation.
63pub struct DispatchOutput {
64    /// Observations from tool execution.
65    pub observations: Vec<Observation>,
66    /// Whether the loop should terminate after observation.
67    pub should_terminate: bool,
68    /// Terminal output if set.
69    pub terminal_output: Option<String>,
70}
71
72// ── The phased agent loop ───────────────────────────────────────────
73
74/// The agent loop in a specific phase.
75///
76/// Each phase transition consumes `self` and produces the next phase,
77/// making invalid transitions a compile error.
78pub struct AgentLoop<Phase: AgentPhase> {
79    /// Mutable loop state carried across phases.
80    pub state: LoopState,
81    /// Immutable loop configuration.
82    pub config: LoopConfig,
83    /// Phase-specific data from the previous transition (if any).
84    phase_data: Option<PhaseData>,
85    /// Zero-sized phase marker.
86    _phase: PhantomData<Phase>,
87}
88
89/// Internal carrier for data between phases.
90enum PhaseData {
91    Reasoning(ReasoningOutput),
92    Policy(PolicyOutput),
93    Dispatch(DispatchOutput),
94}
95
96impl AgentLoop<Reasoning> {
97    /// Create a new agent loop in the Reasoning phase.
98    pub fn new(state: LoopState, config: LoopConfig) -> Self {
99        Self {
100            state,
101            config,
102            phase_data: None,
103            _phase: PhantomData,
104        }
105    }
106
107    /// Run the reasoning step: invoke the inference provider and parse actions.
108    ///
109    /// Consumes `self` and produces `AgentLoop<PolicyCheck>`.
110    pub async fn produce_output(
111        mut self,
112        provider: &dyn InferenceProvider,
113        context_manager: &dyn ContextManager,
114    ) -> Result<AgentLoop<PolicyCheck>, LoopTermination> {
115        self.state.current_phase = "reasoning".into();
116
117        // Check termination conditions before reasoning
118        if self.state.iteration >= self.config.max_iterations {
119            return Err(LoopTermination {
120                reason: LoopTerminationReason::MaxIterations {
121                    iterations: self.state.iteration,
122                },
123                state: self.state,
124            });
125        }
126        if self.state.total_usage.total_tokens >= self.config.max_total_tokens {
127            return Err(LoopTermination {
128                reason: LoopTerminationReason::MaxTokens {
129                    tokens: self.state.total_usage.total_tokens,
130                },
131                state: self.state,
132            });
133        }
134
135        // Apply context management (truncate conversation to fit budget)
136        context_manager.manage_context(
137            &mut self.state.conversation,
138            self.config.context_token_budget,
139        );
140
141        // Drain pending observations. Tool results are already in the conversation
142        // (added by dispatch_tools for approved actions, and by check_policy for
143        // denied tool calls). Policy denials are also already added as tool_result
144        // messages in check_policy, so we don't need to add them again here.
145        self.state.pending_observations.clear();
146
147        // Build inference options
148        let options = crate::reasoning::inference::InferenceOptions {
149            max_tokens: self
150                .config
151                .max_total_tokens
152                .saturating_sub(self.state.total_usage.total_tokens)
153                .min(16384),
154            temperature: self.config.temperature,
155            tool_definitions: self.config.tool_definitions.clone(),
156            ..Default::default()
157        };
158
159        // Call the inference provider
160        let response = match provider.complete(&self.state.conversation, &options).await {
161            Ok(r) => r,
162            Err(e) => {
163                return Err(LoopTermination {
164                    reason: LoopTerminationReason::Error {
165                        message: format!("Inference failed: {}", e),
166                    },
167                    state: self.state,
168                });
169            }
170        };
171
172        // Track usage
173        self.state.add_usage(&response.usage);
174
175        // Parse the response into proposed actions
176        let proposed_actions = if response.has_tool_calls() {
177            // Add the assistant message with tool calls to conversation
178            let tool_calls: Vec<crate::reasoning::conversation::ToolCall> = response
179                .tool_calls
180                .iter()
181                .map(|tc| crate::reasoning::conversation::ToolCall {
182                    id: tc.id.clone(),
183                    name: tc.name.clone(),
184                    arguments: tc.arguments.clone(),
185                })
186                .collect();
187            self.state.conversation.push(
188                crate::reasoning::conversation::ConversationMessage::assistant_tool_calls(
189                    tool_calls,
190                ),
191            );
192
193            response
194                .tool_calls
195                .into_iter()
196                .map(|tc| ProposedAction::ToolCall {
197                    call_id: tc.id,
198                    name: tc.name,
199                    arguments: tc.arguments,
200                })
201                .collect()
202        } else {
203            // Text response → terminal action
204            self.state.conversation.push(
205                crate::reasoning::conversation::ConversationMessage::assistant(&response.content),
206            );
207
208            vec![ProposedAction::Respond {
209                content: response.content,
210            }]
211        };
212
213        self.state.iteration += 1;
214
215        Ok(AgentLoop {
216            state: self.state,
217            config: self.config,
218            phase_data: Some(PhaseData::Reasoning(ReasoningOutput { proposed_actions })),
219            _phase: PhantomData,
220        })
221    }
222}
223
224impl AgentLoop<PolicyCheck> {
225    /// Return a clone of the proposed actions from the reasoning phase.
226    /// Used by the loop driver to emit `ReasoningComplete` journal events
227    /// before the policy check consumes the data.
228    pub fn proposed_actions(&self) -> Vec<ProposedAction> {
229        match &self.phase_data {
230            Some(PhaseData::Reasoning(output)) => output.proposed_actions.clone(),
231            _ => Vec::new(),
232        }
233    }
234
235    /// Evaluate all proposed actions against the policy gate.
236    ///
237    /// Consumes `self` and produces `AgentLoop<ToolDispatching>`.
238    pub async fn check_policy(
239        mut self,
240        gate: &dyn ReasoningPolicyGate,
241    ) -> Result<AgentLoop<ToolDispatching>, LoopTermination> {
242        self.state.current_phase = "policy_check".into();
243
244        let reasoning_output = match self.phase_data {
245            Some(PhaseData::Reasoning(output)) => output,
246            _ => {
247                return Err(LoopTermination {
248                    reason: LoopTerminationReason::Error {
249                        message: "Invalid phase data: expected ReasoningOutput".into(),
250                    },
251                    state: self.state,
252                });
253            }
254        };
255
256        let mut approved = Vec::new();
257        let mut denied = Vec::new();
258        let mut has_terminal = false;
259        let mut terminal_output = None;
260
261        for action in reasoning_output.proposed_actions {
262            let decision = gate
263                .evaluate_action(&self.state.agent_id, &action, &self.state)
264                .await;
265
266            match decision {
267                LoopDecision::Allow => {
268                    if matches!(
269                        action,
270                        ProposedAction::Respond { .. } | ProposedAction::Terminate { .. }
271                    ) {
272                        has_terminal = true;
273                        if let ProposedAction::Respond { ref content } = action {
274                            terminal_output = Some(content.clone());
275                        }
276                        if let ProposedAction::Terminate { ref output, .. } = action {
277                            terminal_output = Some(output.clone());
278                        }
279                    }
280                    approved.push(action);
281                }
282                LoopDecision::Deny { reason } => {
283                    // For tool calls, add a tool_result to the conversation so the
284                    // Anthropic API constraint (every tool_use must have a tool_result)
285                    // is maintained. Without this, denied tool calls leave orphaned
286                    // tool_use blocks that cause API errors.
287                    if let ProposedAction::ToolCall {
288                        ref call_id,
289                        ref name,
290                        ..
291                    } = action
292                    {
293                        self.state.conversation.push(
294                            crate::reasoning::conversation::ConversationMessage::tool_result(
295                                call_id,
296                                name,
297                                format!("[Policy denied] {}", reason),
298                            ),
299                        );
300                    }
301                    // Also feed denial back as pending observation for the loop driver
302                    self.state
303                        .pending_observations
304                        .push(Observation::policy_denial(&reason));
305                    denied.push((action, reason));
306                }
307                LoopDecision::Modify {
308                    modified_action,
309                    reason,
310                } => {
311                    tracing::info!("Policy modified action: {}", reason);
312                    if matches!(
313                        *modified_action,
314                        ProposedAction::Respond { .. } | ProposedAction::Terminate { .. }
315                    ) {
316                        has_terminal = true;
317                        if let ProposedAction::Respond { ref content } = *modified_action {
318                            terminal_output = Some(content.clone());
319                        }
320                    }
321                    approved.push(*modified_action);
322                }
323            }
324        }
325
326        Ok(AgentLoop {
327            state: self.state,
328            config: self.config,
329            phase_data: Some(PhaseData::Policy(PolicyOutput {
330                approved_actions: approved,
331                denied_reasons: denied,
332                has_terminal_action: has_terminal,
333                terminal_output,
334            })),
335            _phase: PhantomData,
336        })
337    }
338}
339
340impl AgentLoop<ToolDispatching> {
341    /// Return (action_count, denied_count) from the policy phase.
342    pub fn policy_summary(&self) -> (usize, usize) {
343        match &self.phase_data {
344            Some(PhaseData::Policy(output)) => (
345                output.approved_actions.len() + output.denied_reasons.len(),
346                output.denied_reasons.len(),
347            ),
348            _ => (0, 0),
349        }
350    }
351
352    /// Dispatch approved actions through the executor.
353    ///
354    /// Consumes `self` and produces `AgentLoop<Observing>`.
355    pub async fn dispatch_tools(
356        mut self,
357        executor: &dyn ActionExecutor,
358        circuit_breakers: &CircuitBreakerRegistry,
359    ) -> Result<AgentLoop<Observing>, LoopTermination> {
360        self.state.current_phase = "tool_dispatching".into();
361
362        let policy_output = match self.phase_data {
363            Some(PhaseData::Policy(output)) => output,
364            _ => {
365                return Err(LoopTermination {
366                    reason: LoopTerminationReason::Error {
367                        message: "Invalid phase data: expected PolicyOutput".into(),
368                    },
369                    state: self.state,
370                });
371            }
372        };
373
374        // If we have a terminal action, skip tool dispatch
375        if policy_output.has_terminal_action {
376            return Ok(AgentLoop {
377                state: self.state,
378                config: self.config,
379                phase_data: Some(PhaseData::Dispatch(DispatchOutput {
380                    observations: Vec::new(),
381                    should_terminate: true,
382                    terminal_output: policy_output.terminal_output,
383                })),
384                _phase: PhantomData,
385            });
386        }
387
388        // Dispatch tool calls in parallel
389        let observations = executor
390            .execute_actions(
391                &policy_output.approved_actions,
392                &self.config,
393                circuit_breakers,
394            )
395            .await;
396
397        // Add tool results to conversation
398        for obs in &observations {
399            let tool_call_id = obs.call_id.as_deref().unwrap_or(&obs.source);
400            if !obs.is_error {
401                self.state.conversation.push(
402                    crate::reasoning::conversation::ConversationMessage::tool_result(
403                        tool_call_id,
404                        &obs.source,
405                        &obs.content,
406                    ),
407                );
408            } else {
409                self.state.conversation.push(
410                    crate::reasoning::conversation::ConversationMessage::tool_result(
411                        tool_call_id,
412                        &obs.source,
413                        format!("[Error] {}", obs.content),
414                    ),
415                );
416            }
417        }
418
419        Ok(AgentLoop {
420            state: self.state,
421            config: self.config,
422            phase_data: Some(PhaseData::Dispatch(DispatchOutput {
423                observations,
424                should_terminate: false,
425                terminal_output: None,
426            })),
427            _phase: PhantomData,
428        })
429    }
430}
431
432/// What happens after the observation phase.
433pub enum LoopContinuation {
434    /// Continue to the next iteration.
435    Continue(Box<AgentLoop<Reasoning>>),
436    /// The loop is complete.
437    Complete(LoopResult),
438}
439
440impl AgentLoop<Observing> {
441    /// Return observation count from the dispatch phase.
442    /// Used by the loop driver to emit `ObservationsCollected` journal events.
443    pub fn observation_count(&self) -> usize {
444        match &self.phase_data {
445            Some(PhaseData::Dispatch(output)) => output.observations.len(),
446            _ => 0,
447        }
448    }
449
450    /// Collect observations and decide whether to continue or terminate.
451    ///
452    /// Consumes `self` and returns either a new Reasoning phase or the final result.
453    pub fn observe_results(mut self) -> LoopContinuation {
454        self.state.current_phase = "observing".into();
455
456        let dispatch_output = match self.phase_data {
457            Some(PhaseData::Dispatch(output)) => output,
458            _ => {
459                return LoopContinuation::Complete(LoopResult {
460                    output: String::new(),
461                    iterations: self.state.iteration,
462                    total_usage: self.state.total_usage.clone(),
463                    termination_reason: TerminationReason::Error {
464                        message: "Invalid phase data".into(),
465                    },
466                    duration: self.state.elapsed().to_std().unwrap_or_default(),
467                    conversation: self.state.conversation,
468                });
469            }
470        };
471
472        if dispatch_output.should_terminate {
473            return LoopContinuation::Complete(LoopResult {
474                output: dispatch_output.terminal_output.unwrap_or_default(),
475                iterations: self.state.iteration,
476                total_usage: self.state.total_usage.clone(),
477                termination_reason: TerminationReason::Completed,
478                duration: self.state.elapsed().to_std().unwrap_or_default(),
479                conversation: self.state.conversation,
480            });
481        }
482
483        // Add observations as pending for next reasoning step
484        self.state
485            .pending_observations
486            .extend(dispatch_output.observations);
487
488        LoopContinuation::Continue(Box::new(AgentLoop {
489            state: self.state,
490            config: self.config,
491            phase_data: None,
492            _phase: PhantomData,
493        }))
494    }
495}
496
497/// Reasons the loop may terminate during a phase transition.
498/// Carries the state so the caller can extract final conversation/usage.
499#[derive(Debug)]
500pub struct LoopTermination {
501    pub reason: LoopTerminationReason,
502    pub state: LoopState,
503}
504
505/// The specific reason for termination.
506#[derive(Debug)]
507pub enum LoopTerminationReason {
508    MaxIterations { iterations: u32 },
509    MaxTokens { tokens: u32 },
510    Timeout,
511    Error { message: String },
512}
513
514impl LoopTermination {
515    /// Convert to a LoopResult for the caller.
516    pub fn into_result(self) -> LoopResult {
517        let reason = match &self.reason {
518            LoopTerminationReason::MaxIterations { .. } => TerminationReason::MaxIterations,
519            LoopTerminationReason::MaxTokens { .. } => TerminationReason::MaxTokens,
520            LoopTerminationReason::Timeout => TerminationReason::Timeout,
521            LoopTerminationReason::Error { message } => TerminationReason::Error {
522                message: message.clone(),
523            },
524        };
525        LoopResult {
526            output: String::new(),
527            iterations: self.state.iteration,
528            total_usage: self.state.total_usage.clone(),
529            termination_reason: reason,
530            duration: self.state.elapsed().to_std().unwrap_or_default(),
531            conversation: self.state.conversation,
532        }
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::reasoning::conversation::Conversation;
540    use crate::types::AgentId;
541
542    #[test]
543    fn test_agent_loop_creation() {
544        let state = LoopState::new(AgentId::new(), Conversation::with_system("test"));
545        let config = LoopConfig::default();
546        let loop_instance = AgentLoop::<Reasoning>::new(state, config);
547        assert_eq!(loop_instance.state.iteration, 0);
548    }
549
550    #[test]
551    fn test_loop_termination_into_result() {
552        let state = LoopState::new(AgentId::new(), Conversation::new());
553        let termination = LoopTermination {
554            reason: LoopTerminationReason::MaxIterations { iterations: 25 },
555            state,
556        };
557        let result = termination.into_result();
558        assert!(matches!(
559            result.termination_reason,
560            TerminationReason::MaxIterations
561        ));
562    }
563
564    // Compile-time verification:
565    // The following function signatures prove that the type system
566    // prevents invalid phase transitions. If any of these didn't
567    // compile, it would mean the typestate pattern is broken.
568
569    fn _prove_reasoning_to_policy(_loop: AgentLoop<Reasoning>) {
570        // This function takes a Reasoning phase loop.
571        // The only method available is `produce_output()`, which
572        // returns AgentLoop<PolicyCheck>.
573        // You cannot call check_policy() or dispatch_tools() here.
574    }
575
576    fn _prove_policy_to_dispatch(_loop: AgentLoop<PolicyCheck>) {
577        // This function takes a PolicyCheck phase loop.
578        // The only method available is `check_policy()`, which
579        // returns AgentLoop<ToolDispatching>.
580    }
581
582    fn _prove_dispatch_to_observing(_loop: AgentLoop<ToolDispatching>) {
583        // This function takes a ToolDispatching phase loop.
584        // The only method available is `dispatch_tools()`, which
585        // returns AgentLoop<Observing>.
586    }
587
588    fn _prove_observing_to_continuation(_loop: AgentLoop<Observing>) {
589        // This function takes an Observing phase loop.
590        // The only method available is `observe_results()`, which
591        // returns LoopContinuation (either Continue<Reasoning> or Complete).
592    }
593}