Orchestration

The Battalion runtime in crates/paladin-battalion/ coordinates multiple Paladin agents through a family of orchestration patterns, a strategy router (Commander), a cron-style job scheduler, and an event/trigger system. This guide is the comprehensive reference for choosing a pattern and wiring it up.

For a quick pattern-by-pattern cheat sheet see Battalion Patterns; for the declarative flow language see Maneuver Flow DSL; for how agents and workflows call each other see the Agent ↔ Orchestrator Bridge. For how a Battalion fits among the ways to run agents, see Deployment Topologies.

Every code example targets the current v0.5.0 workspace. The substantive examples are real, compiled code pulled from the paladin-doc-examples crate via mdBook {{#include}}, so they are checked against the live API; a few illustrative fragments are marked rust,ignore. The API forms are verified against crates/paladin-battalion/ and crates/paladin-ports/.


Table of Contents

  1. Workflow Patterns Overview
  2. Formation — Sequential
  3. Phalanx — Parallel
  4. Campaign — Graph / DAG
  5. Chain of Command — Hierarchical
  6. Commander — Dynamic Strategy Routing
  7. Job Scheduling
  8. Event and Trigger System
  9. Configuration Reference
  10. See Also

Workflow Patterns Overview

All orchestration services depend only on Arc<dyn PaladinPort> (from paladin-ports) — they never import an LLM provider crate directly. Pick a pattern by the shape of the work:

PatternServiceExecution modelUse when
FormationFormationExecutionServiceSequential, output N → input N+1Multi-step pipelines where each stage refines the previous
PhalanxPhalanxExecutionServiceConcurrent, same input to allIndependent analyses you want fanned out in parallel
CampaignCampaignExecutionServiceDAG / topologicalBranching workflows with explicit dependencies
Chain of CommandChainOfCommandExecutionServiceHierarchical delegationA commander decomposing work to specialists
CommanderCommander / CommanderBuilderAuto-routes to a patternThe right pattern varies per request

Conclave (mixture-of-experts), Council (turn-taking discussion), and Grove (semantic routing) are additional patterns documented in Battalion Patterns. The declarative Maneuver flow DSL has its own guide: Maneuver Flow DSL.

Decision Flowchart

flowchart TD
    start([Have a task + several Paladins]) --> q1{One fixed order of steps?}
    q1 -->|Yes| formation[Formation — sequential]
    q1 -->|No| q2{Steps independent, run together?}
    q2 -->|Yes| phalanx[Phalanx — parallel]
    q2 -->|No| q3{Explicit dependencies / branches?}
    q3 -->|Yes| campaign[Campaign — DAG]
    q3 -->|No| q4{A lead agent should delegate?}
    q4 -->|Yes| chain[Chain of Command]
    q4 -->|No| q5{Pattern varies per request?}
    q5 -->|Yes| commander[Commander — auto-route]
    q5 -->|No| formation

Formation — Sequential

Source: crates/paladin-battalion/src/formation_service.rs

Each Paladin's output becomes the next Paladin's input. Ideal for refinement pipelines (extract → analyze → write). If a stage fails, the configured ErrorStrategy decides whether the chain short-circuits (FailFast, the default) or continues.

#![allow(unused)]
fn main() {
use paladin_battalion::formation_service::FormationExecutionService;
use paladin_core::platform::container::battalion::formation::Formation;
use paladin_core::platform::container::battalion::{BattalionConfig, ErrorStrategy};

/// Run three Paladins in sequence; each one's output feeds the next.
pub async fn run_formation() -> Result<(), Box<dyn std::error::Error>> {
    let paladin_port = mock_paladin_port();
    let extractor = create_paladin("Extractor");
    let analyzer = create_paladin("Analyzer");
    let writer = create_paladin("Writer");

    let config = BattalionConfig {
        error_strategy: ErrorStrategy::FailFast, // first failure aborts the chain
        ..Default::default()
    };
    let formation = Formation::new(vec![extractor, analyzer, writer], config)?;

    let service = FormationExecutionService::new(paladin_port);
    let result = service
        .execute(&formation, "Raw Q3 earnings data...")
        .await?;

    println!("Final output: {}", result.final_output);
    Ok(())
}
}

Error handling / short-circuit: with ErrorStrategy::FailFast the first failing stage stops the Formation and returns the error. With ContinueOnError, a failed stage is skipped and its input is passed through to the next stage. Keep chains short (≤5) for latency-sensitive paths — each stage is one sequential LLM round-trip.


Phalanx — Parallel

Source: crates/paladin-battalion/src/phalanx_service.rs

Every Paladin receives the same input and runs concurrently on tokio tasks. Results are combined according to an AggregationStrategy, and concurrency is bounded by Phalanx::with_max_concurrency so you don't exceed LLM rate limits.

#![allow(unused)]
fn main() {
use paladin_battalion::phalanx_service::PhalanxExecutionService;
use paladin_core::platform::container::battalion::phalanx::{AggregationStrategy, Phalanx};

/// Fan the same input out to several Paladins concurrently, then aggregate.
pub async fn run_phalanx() -> Result<(), Box<dyn std::error::Error>> {
    let paladin_port = mock_paladin_port();
    let security = create_paladin("SecurityAuditor");
    let perf = create_paladin("PerformanceAnalyst");
    let style = create_paladin("StyleChecker");

    let phalanx = Phalanx::new(vec![security, perf, style], BattalionConfig::default())?
        .with_aggregation(AggregationStrategy::CollectAll)
        .with_max_concurrency(4); // cap concurrent Paladins

    let service = PhalanxExecutionService::new(paladin_port);
    let result = service
        .execute(&phalanx, "Review this Rust module...")
        .await?;

    println!("Aggregated: {}", result.final_output);
    Ok(())
}
}

AggregationStrategy variants: CollectAll (gather all outputs), FirstSuccess (first to finish wins), Majority (consensus), and Custom(String).


Campaign — Graph / DAG

Source: crates/paladin-battalion/src/campaign_service.rs

Paladins are arranged in a directed acyclic graph. The service topologically sorts the graph so every upstream node completes before its downstream nodes start; independent branches run concurrently. Campaign::build() rejects cycles.

#![allow(unused)]
fn main() {
use paladin_battalion::campaign_service::CampaignExecutionService;
use paladin_core::platform::container::battalion::campaign::{
    Campaign, CampaignEdge, EdgeCondition,
};

/// Arrange Paladins as a DAG: `ingest → analyze → report`.
pub async fn run_campaign() -> Result<(), Box<dyn std::error::Error>> {
    let paladin_port = mock_paladin_port();

    let mut campaign = Campaign::new(BattalionConfig::default());
    let ingest = campaign.add_paladin(create_paladin("Ingest"));
    let analyze = campaign.add_paladin(create_paladin("Analyze"));
    let report = campaign.add_paladin(create_paladin("Report"));

    // Edges define dependencies; `EdgeCondition::Always` is unconditional.
    campaign.add_edge(CampaignEdge::new(ingest, analyze, EdgeCondition::Always))?;
    // A conditional edge only traverses when the upstream output matches:
    campaign.add_edge(CampaignEdge::new(
        analyze,
        report,
        EdgeCondition::Contains("ready".to_string()),
    ))?;
    campaign.set_entry_point(ingest)?;

    let service = CampaignExecutionService::new(paladin_port);
    let result = service.execute(&campaign, "Start").await?;

    println!("Campaign output: {}", result.final_output);
    Ok(())
}
}

Paladins are added with add_paladin (returning a Uuid), wired with add_edge using CampaignEdge::new(source, target, condition), and the graph's start is set with set_entry_point. Use EdgeCondition::Contains/Regex for conditional branching; validate() (called by execute) rejects cycles.


Chain of Command — Hierarchical

Source: crates/paladin-battalion/src/chain_of_command_service.rs

A commander Paladin decomposes the task, routes sub-tasks to specialist (subordinate) Paladins, and synthesizes their outputs into a final answer.

#![allow(unused)]
fn main() {
use paladin_battalion::chain_of_command_service::ChainOfCommandExecutionService;
use paladin_core::platform::container::battalion::chain_of_command::ChainOfCommand;

/// A commander Paladin delegates to specialists and synthesizes their work.
pub async fn run_chain_of_command() -> Result<(), Box<dyn std::error::Error>> {
    let paladin_port = mock_paladin_port();
    let commander = create_paladin("Commander");
    let specialists = vec![
        create_paladin("BackendDev"),
        create_paladin("FrontendDev"),
        create_paladin("QaEngineer"),
    ];

    let chain = ChainOfCommand::new(commander, specialists, BattalionConfig::default())?;

    let service = ChainOfCommandExecutionService::new(paladin_port);
    let result = service.execute(&chain, "Build a login feature").await?;

    println!("Selected specialists: {:?}", result.selected_specialists);
    println!("Reasoning: {}", result.reasoning);
    for output in &result.outputs {
        println!("- {output}");
    }
    Ok(())
}
}

The service returns a DelegationResult with selected_specialists, reasoning, and the specialists' outputs. Give each subordinate a distinct agent_description so the commander can route accurately.


Commander — Dynamic Strategy Routing

Source: crates/paladin-battalion/src/commander.rs

The Commander is a single entry-point that selects a pattern automatically (Auto mode) based on the input text and the number/capabilities of the Paladins, or runs an explicit strategy you name. It also collects rich telemetry and can export execution metadata to JSON.

Auto mode

#![allow(unused)]
fn main() {
use paladin_battalion::commander::CommanderBuilder;
use paladin_core::platform::container::battalion::BattalionStrategy;

/// Let the Commander auto-select the best pattern for the input.
pub async fn run_commander_auto() -> Result<(), Box<dyn std::error::Error>> {
    let paladin_port = mock_paladin_port();

    let commander = CommanderBuilder::new(paladin_port)
        .strategy(BattalionStrategy::Auto)
        .paladins(vec![
            create_paladin("Analyzer"),
            create_paladin("Processor"),
            create_paladin("Synthesizer"),
        ])
        .build()?;

    let result = commander
        .execute("Analyze and summarize this report")
        .await?;

    println!("Strategy selected: {:?}", result.strategy_used);
    if let Some(reason) = &result.strategy_selection_reasoning {
        println!("Reasoning: {reason}");
    }
    println!("Output: {}", result.final_output);
    Ok(())
}
}

Explicit strategy

let commander = CommanderBuilder::new(paladin_port)
    .strategy(BattalionStrategy::Formation) // force a specific pattern
    .paladins(pipeline_paladins)
    .build()?;
let result = commander.execute(input).await?;

Auto-mode heuristics (first match wins)

PriorityStrategyTrigger keywordsMin Paladins
1Conclavesynthesize, compare, perspectives, consensus, aggregate3+
2Councildiscuss, debate, deliberate, brainstorm, dialogue2+
3Groveroute, best agent, expertise, most qualified2+
4Campaignworkflow, graph, conditional, depends on, multi-stageany
5Formationsequential, pipeline, chain, step by step, in orderany
6Phalanxparallel, concurrent, simultaneously, in parallelany
7ChainOfCommanddelegate, hierarchy, specialist, coordinatorany
8Formationfallback — no keywords matchedany

Maneuver is explicit-only and is never chosen by Auto mode. Strategy selection typically adds ~0–5 ms of overhead; the decision is reported in result.strategy_selection_reasoning.

Metadata export

Point the Commander at a directory and it writes one JSON file per execution ({strategy}_{timestamp}_{uuid}.json) for audit, cost, and performance analysis.

use paladin_core::platform::container::battalion::BattalionConfig;
use std::path::PathBuf;

let config = BattalionConfig::new("audited_battalion")
    .with_metadata_dir(PathBuf::from("./battalion_metadata"));

let commander = CommanderBuilder::new(paladin_port)
    .strategy(BattalionStrategy::Auto)
    .paladins(paladins)
    .config(config)
    .build()?;

let result = commander.execute(input).await?;
// Metadata written to ./battalion_metadata/{strategy}_{timestamp}_{uuid}.json

Each file records battalion_id, strategy_used, duration_ms, total_tokens, per-Paladin paladin_results (output, execution_time_ms, token_count, stop_reason), per_paladin_times, per_paladin_tokens, and strategy_selection_reasoning.


Job Scheduling

Source: crates/paladin-ports/src/output/scheduler_port.rs and queue_port.rs

The scheduler runs jobs on a 6-field cron schedule; the queue ports manage asynchronous work items. A Redis-backed implementation is gated behind the root redis-queue feature.

Prerequisites: the Redis-backed queue requires the redis-queue feature and a running Redis instance. Run make dev to start it (alongside MinIO, MySQL, Qdrant).

Scheduling a recurring job

JobSpec carries a human label, a cron expression, and arbitrary metadata. SchedulerPort returns a JobId you can use to query status or cancel.

#![allow(unused)]
fn main() {
use paladin_ports::output::scheduler_port::{JobSpec, JobStatus, SchedulerPort};

/// Schedule a recurring job with a 6-field cron expression.
pub async fn run_scheduling() -> Result<(), Box<dyn std::error::Error>> {
    let scheduler: Arc<dyn SchedulerPort> = mock_scheduler();
    scheduler.start().await?;

    // 6-field cron: sec min hour day month weekday
    let spec = JobSpec::new("daily-digest", "0 0 9 * * *") // every day at 09:00:00
        .with_metadata("workflow", "news-digest");

    let job_id = scheduler.schedule_job(spec).await?;

    let status: JobStatus = scheduler.get_job_status(&job_id).await?;
    println!("job {job_id:?} is {status:?}");

    // Later: scheduler.cancel_job(&job_id).await?;
    Ok(())
}
}

JobStatus lifecycle: Scheduled → Running → Completed (or Failed { .. } / Cancelled). JobInfo (from get_job_info) adds created_at, last_run, next_run, run_count, and failure_count.

Queue management, retry, and timeouts

The FullQueuePort trait composes enqueue/dequeue, batch, priority, and management operations (pause_queue, resume_queue, retry_item, purge_failed, get_queue_stats). Retry and timeout behavior for battalion execution is controlled by the battalion.retry and battalion.default_timeout_seconds configuration (see Configuration Reference).

use paladin_ports::output::queue_port::{FullQueuePort, QueueStats};

let stats: QueueStats = queue.get_queue_stats("news-digest").await?;
println!("pending: {}, processing: {}", stats.pending_items, stats.processing_items);

// Retry a failed item or purge the dead-letter set
queue.retry_item("news-digest", item_id).await?;
let purged = queue.purge_failed("news-digest").await?;

Event and Trigger System

Source: crates/paladin-core/src/platform/container/trigger.rs

A Trigger binds an incoming event to an action when a TriggerCondition matches. Events are matched by event_type_pattern, optional source_pattern, payload conditions, minimum priority, and optional TimeCondition windows (active hours/days and a cooldown).

Defining a condition and firing an event

Build a TriggerCondition (and TriggerConfig), then fire a matching event through the orchestrator bridge. fire_event returns an EventDispatchResult reporting how many triggers matched and their IDs.

#![allow(unused)]
fn main() {
use paladin_core::base::entity::message::MessagePriority;
use paladin_core::platform::container::trigger::{TimeCondition, TriggerCondition, TriggerConfig};
use paladin_ports::output::orchestrator_port::{FireEventRequest, OrchestratorPort};

/// Build a trigger condition and fire a matching event.
pub async fn run_events() -> Result<(), Box<dyn std::error::Error>> {
    let condition = TriggerCondition {
        event_type_pattern: "critical_finding".to_string(),
        source_pattern: Some("security-*".to_string()),
        payload_conditions: vec![],
        min_priority: Some(MessagePriority::High),
        time_conditions: Some(TimeCondition {
            active_hours: Some((9, 17)),            // only 09:00–17:00
            active_days: Some(vec![1, 2, 3, 4, 5]), // Mon–Fri
            cooldown_seconds: Some(300),            // at most once per 5 min
        }),
    };

    let config = TriggerConfig {
        max_retries: 3,
        timeout_seconds: 60,
        preserve_after_completion: false,
        ttl_seconds: 3600,
        processing_priority: MessagePriority::High,
    };

    // Fire an event through the orchestrator bridge.
    let orchestrator = mock_orchestrator();
    let result = orchestrator
        .fire_event(FireEventRequest {
            event_type: "critical_finding".to_string(),
            payload: serde_json::json!({ "severity": "high", "cve": "CVE-2025-0001" }),
            source: "security-scanner".to_string(),
        })
        .await?;

    println!(
        "{} trigger(s) fired: {:?}",
        result.triggered_count, result.trigger_ids
    );
    Ok(())
}
}

A matched trigger initiates the bound workflow (e.g. scheduling a job or queuing a Paladin run). See the Agent ↔ Orchestrator Bridge for end-to-end recipes that combine events, triggers, and agent execution.


Configuration Reference

All battalion behavior is configurable through the battalion: section of config.yml:

battalion:
  default_timeout_seconds: 300     # Per-battalion execution timeout
  error_strategy: "fail_fast"      # fail_fast | continue_on_error | retry_then_continue
  max_concurrent_paladins: 10      # Phalanx concurrency limit
  metadata_output_enabled: false   # Write execution metadata to files

  retry:                           # Used when error_strategy = retry_then_continue
    max_attempts: 3
    exponential_backoff: true
    jitter: true
    base_delay_ms: 100
    max_delay_seconds: 10

Environment overrides follow the APP_BATTALION_* convention (e.g. APP_BATTALION_ERROR_STRATEGY, APP_BATTALION_MAX_CONCURRENT_PALADINS). See Configuration for the full schema.

BattalionResult (returned by the Formation/Phalanx/Campaign/Commander services) exposes: final_output: String, paladin_results: Vec<PaladinResult>, status: BattalionStatus, strategy_used: BattalionStrategy, total_tokens: u64, per_paladin_times, and per_paladin_tokens. (Chain of Command returns a DelegationResult instead.)


See Also