orchestral-runtime 0.1.0

Thread Runtime with concurrency, interruption, scheduling, LLM planners, actions, and API for Orchestral
Documentation
use super::*;
use crate::orchestrator::support::{summarize_available_bindings, summarize_binding_shapes};
use orchestral_core::types::Plan;

const MAX_RECENT_OBSERVATIONS: usize = 4;

#[derive(Debug, Default, Clone)]
pub(super) struct AgentLoopState {
    recent_observations: Vec<String>,
    completed_step_ids: Vec<String>,
    available_bindings: Vec<String>,
    binding_shapes: Vec<String>,
    working_set_preview: Option<String>,
}

impl AgentLoopState {
    pub(super) fn planner_loop_context(
        &self,
        iteration: usize,
        max_iterations: usize,
    ) -> Option<PlannerLoopContext> {
        if self.recent_observations.is_empty()
            && self.completed_step_ids.is_empty()
            && self.available_bindings.is_empty()
            && self.binding_shapes.is_empty()
            && self.working_set_preview.is_none()
        {
            return None;
        }

        Some(PlannerLoopContext {
            iteration,
            max_iterations,
            recent_observations: self.recent_observations.clone(),
            completed_step_ids: self.completed_step_ids.clone(),
            available_bindings: self.available_bindings.clone(),
            binding_shapes: self.binding_shapes.clone(),
            working_set_preview: self.working_set_preview.clone(),
        })
    }

    pub(super) fn record_iteration(
        &mut self,
        iteration: usize,
        execution_mode: &str,
        plan: &Plan,
        result: &ExecutionResult,
        task: &Task,
    ) {
        let observation =
            build_iteration_observation(iteration, execution_mode, plan, result, task);
        self.recent_observations.push(observation);
        if self.recent_observations.len() > MAX_RECENT_OBSERVATIONS {
            let drain = self.recent_observations.len() - MAX_RECENT_OBSERVATIONS;
            self.recent_observations.drain(0..drain);
        }

        self.completed_step_ids = task
            .completed_step_ids
            .iter()
            .map(|id| id.to_string())
            .collect();
        self.available_bindings = summarize_available_bindings(&task.working_set_snapshot);
        self.binding_shapes = summarize_binding_shapes(&task.working_set_snapshot);
        let preview = summarize_working_set(&task.working_set_snapshot);
        self.working_set_preview = if preview.trim().is_empty() || preview == "(empty)" {
            None
        } else {
            Some(preview)
        };
    }

    pub(super) fn observation_count(&self) -> usize {
        self.recent_observations.len()
    }
}

pub(super) fn should_continue_agent_loop(
    result: &ExecutionResult,
    iteration: usize,
    max_iterations: usize,
) -> bool {
    iteration < max_iterations
        && matches!(
            result,
            ExecutionResult::Completed | ExecutionResult::Failed { .. }
        )
}

pub(super) fn agent_loop_iteration_limit_error(max_iterations: usize) -> String {
    format!(
        "planner iteration limit reached after {} rounds without a terminal DONE/NEED_INPUT decision",
        max_iterations
    )
}

fn build_iteration_observation(
    iteration: usize,
    execution_mode: &str,
    plan: &Plan,
    result: &ExecutionResult,
    task: &Task,
) -> String {
    let mut observation = format!(
        "iteration {} ran {} plan '{}' ({} step(s): {})",
        iteration,
        execution_mode,
        plan.goal,
        plan.steps.len(),
        summarize_plan_actions(plan)
    );
    match result {
        ExecutionResult::Completed => {
            observation.push_str("; result=completed");
            if let Some(summary) = task
                .working_set_snapshot
                .get("summary")
                .and_then(Value::as_str)
                .filter(|value| !value.trim().is_empty())
            {
                observation.push_str(&format!("; summary={}", truncate_for_log(summary, 240)));
            }
        }
        ExecutionResult::Failed { step_id, error } => {
            observation.push_str(&format!(
                "; result=failed at {}: {}",
                step_id,
                truncate_for_log(error, 240)
            ));
        }
        ExecutionResult::WaitingUser { prompt, .. } => {
            observation.push_str(&format!(
                "; result=waiting_user: {}",
                truncate_for_log(prompt, 240)
            ));
        }
        ExecutionResult::WaitingEvent {
            step_id,
            event_type,
        } => {
            observation.push_str(&format!(
                "; result=waiting_event at {} for {}",
                step_id, event_type
            ));
        }
    }
    observation
}

fn summarize_plan_actions(plan: &Plan) -> String {
    const MAX_STEPS: usize = 6;
    let mut parts = plan
        .steps
        .iter()
        .take(MAX_STEPS)
        .map(|step| format!("{}:{}", step.id, step.action))
        .collect::<Vec<_>>();
    if plan.steps.len() > MAX_STEPS {
        parts.push(format!("... +{} more", plan.steps.len() - MAX_STEPS));
    }
    parts.join(", ")
}