The Itinera bookRust edition

Part 5: Designing with Itinera, Chapter 43

Working with agents

What you want to do

The shop now works with coding agents, which change its steps and, sometimes, its workflow. You want an agent to change a business rule without changing how an order travels through the shop, and to know, without reading every line it wrote, whether the flow changed and how.

The code

The program is chapter 30's, with its approved paths, so it has none of what chapters 31 to 35 added to the shop. An agent was asked to cap discounts, and changed the pricing step alone:

Rust, from the program ch43_working_with_agents
/// Works out what an order costs: its price, less its discount if it has one, given back as the
/// amount to charge. A discount takes at most half the price off.
#[derive(Step)]
struct Price {
    #[input]
    price: i64,
    #[input]
    discount: Option<i64>,
}

#[step]
impl Price {
    fn run(self, #[contributor] mut contributor: Contributor<'_>) -> Result<Outcome, Error> {
        let discount = self.discount.unwrap_or(0).min(self.price / 2);
        contributor.contribute("amount", self.price - discount);
        Self::succeed()
    }
}
Rust, the same in its asynchronous form, from the program ch43_working_with_agents_async
/// Works out what an order costs: its price, less its discount if it has one, given back as the
/// amount to charge. A discount takes at most half the price off.
#[derive(Step)]
struct Price {
    #[input]
    price: i64,
    #[input]
    discount: Option<i64>,
}

#[step]
impl Price {
    async fn run(self, #[contributor] mut contributor: Contributor<'_>) -> Result<Outcome, Error> {
        let discount = self.discount.unwrap_or(0).min(self.price / 2);
        contributor.contribute("amount", self.price - discount);
        Self::succeed()
    }
}

Another agent proposes a change to the flow: a parcel the carrier won't collect should be given up at once, rather than tried again with the backup carrier. Before it replaces the workflow, the shop declares the proposal in its tests, beside the approved workflow of chapter 23, with ship and no retry budget, and only the main carrier:

Rust, from the program ch43_working_with_agents
/// The shop's workflow as an agent proposes to change it: a parcel the carrier won't collect
/// is given up at once, without trying the backup carrier.
#[workflow(name = "orders", reporters = [Log, Desk], policies = [Notify])]
#[with_step(Lookup, name = "lookup", policies = [AlreadyCompleted])]
#[with_step(Price, name = "price")]
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit, Outage])]
#[with_step(Ship, name = "ship", policies = [FailedShipping])]
struct Proposed;

#[role]
impl Refunds for Proposed {
    fn refund(&self, receipt: &str, amount: i64) {
        println!("the bank gives {amount} back on receipt {receipt}");
    }
}

#[role]
impl Messages for Proposed {
    fn tell(&self, customer: &str, message: &str) {
        println!("email to {customer}: {message}");
    }
}

#[workflow]
impl Proposed {
    /// Names each journey after its order.
    #[id_generator]
    fn order_number(&self, data: &DataBag) -> Result<String, Error> {
        Ok(data.required("order")?)
    }

    /// Gives the shipping the weight of the order's parcel.
    #[input_adapter(steps = ["ship"])]
    fn parcels(
        &self,
        #[key] key: &str,
        #[from_workflow] parcel: Parcel,
    ) -> Result<Option<AnyValue>, Error> {
        if key != "weight" {
            return Self::from_data_bag();
        }
        Self::supply(parcel.weight)
    }

    /// The order book, which has the orders completed before today.
    #[provider(key = "order_book")]
    fn ledger(&self) -> Result<Provided<dyn OrderBook>, Error> {
        Ok(provide!(Ledger {
            completed: vec!["A-13"],
        }))
    }

    /// The bank, as the payment client of the journey.
    #[provider(key = "payments")]
    fn bank(&self) -> Result<Provided<dyn Payments>, Error> {
        Ok(provide!(Bank { this_year: 2026 }))
    }

    /// The main carrier.
    #[provider(key = "carrier", per_attempt)]
    fn carriers(&self) -> Result<Provided<dyn Carrier>, Error> {
        Ok(provide!(MainCarrier))
    }
}
Rust, the same in its asynchronous form, from the program ch43_working_with_agents_async
/// The shop's workflow as an agent proposes to change it: a parcel the carrier won't collect
/// is given up at once, without trying the backup carrier.
#[workflow(name = "orders", reporters = [Log, Desk], policies = [Notify])]
#[with_step(Lookup, name = "lookup", policies = [AlreadyCompleted])]
#[with_step(Price, name = "price")]
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit, Outage])]
#[with_step(Ship, name = "ship", policies = [FailedShipping])]
struct Proposed;

#[role]
#[async_trait]
impl Refunds for Proposed {
    async fn refund(&self, receipt: &str, amount: i64) {
        network().await;
        println!("the bank gives {amount} back on receipt {receipt}");
    }
}

#[role]
#[async_trait]
impl Messages for Proposed {
    async fn tell(&self, customer: &str, message: &str) {
        network().await;
        println!("email to {customer}: {message}");
    }
}

#[workflow]
impl Proposed {
    /// Names each journey after its order.
    #[id_generator]
    fn order_number(&self, data: &DataBag) -> Result<String, Error> {
        Ok(data.required("order")?)
    }

    /// Gives the shipping the weight of the order's parcel.
    #[input_adapter(steps = ["ship"])]
    fn parcels(
        &self,
        #[key] key: &str,
        #[from_workflow] parcel: Parcel,
    ) -> Result<Option<AnyValue>, Error> {
        if key != "weight" {
            return Self::from_data_bag();
        }
        Self::supply(parcel.weight)
    }

    /// The order book, which has the orders completed before today.
    #[provider(key = "order_book")]
    fn ledger(&self) -> Result<Provided<dyn OrderBook>, Error> {
        Ok(provide!(Ledger {
            completed: vec!["A-13"],
        }))
    }

    /// The bank, as the payment client of the journey.
    #[provider(key = "payments")]
    fn bank(&self) -> Result<Provided<dyn Payments>, Error> {
        Ok(provide!(Bank { this_year: 2026 }))
    }

    /// The main carrier.
    #[provider(key = "carrier", per_attempt)]
    fn carriers(&self) -> Result<Provided<dyn Carrier>, Error> {
        Ok(provide!(MainCarrier))
    }
}

What it names is as earlier chapters show it: the lookup, already_completed, OrderBook and the log in chapter 22, Payments in chapter 14, Carrier in chapter 12, the charge and the audit in chapter 20, RenewCard in chapter 14, the shipping and outage in chapter 17, failed_shipping and Refunds in chapter 23, notify and Messages in chapter 19 and the desk in chapter 11.

Chapter 30's path_of now makes its order with order_of, and proposed_path_of runs the same order through the proposal. NewOrder is as chapter 25 shows it, and Parcel as chapter 21 does:

Rust, from the program ch43_working_with_agents
/// What the shop knows about an order of 30 with a parcel of this weight.
fn order_of(order: &str, weight: i64) -> NewOrder {
    NewOrder {
        order: order.to_owned(),
        customer: "Ann".to_owned(),
        price: 30,
        discount: None,
        parcel: Parcel {
            weight,
            size: [20, 15, 5],
        },
        card: Some("ann-2028".to_owned()),
        gift_card: false,
    }
}

/// Runs an order of 30 with a parcel of this weight, through the shop's real steps and
/// policies, with the test's bank and carriers in place of the shop's, and gives back its
/// journey's path.
fn path_of(
    order: &str,
    weight: i64,
    bank: TestBank,
    carrier: TestCarrier,
) -> Result<Vec<String>, Error> {
    let recorder = RecordingReporter::default();
    let instance = Orders::builder()
        .channel("email")
        .initial_data(order_of(order, weight))
        .reporter(recorder.clone())
        .fake_provider::<dyn Payments>("bank", move || Ok(provide!(bank)))
        .fake_provider::<dyn Carrier>("carriers", move || Ok(provide!(carrier)))
        .create()?;
    local_executor_for!(Orders).run(instance)?;
    Ok(recorder.path())
}

/// Runs the same order as `path_of`, through the proposed workflow.
fn proposed_path_of(
    order: &str,
    weight: i64,
    bank: TestBank,
    carrier: TestCarrier,
) -> Result<Vec<String>, Error> {
    let recorder = RecordingReporter::default();
    let instance = Proposed::builder()
        .initial_data(order_of(order, weight))
        .reporter(recorder.clone())
        .fake_provider::<dyn Payments>("bank", move || Ok(provide!(bank)))
        .fake_provider::<dyn Carrier>("carriers", move || Ok(provide!(carrier)))
        .create()?;
    local_executor_for!(Proposed).run(instance)?;
    Ok(recorder.path())
}
Rust, the same in its asynchronous form, from the program ch43_working_with_agents_async
/// What the shop knows about an order of 30 with a parcel of this weight.
fn order_of(order: &str, weight: i64) -> NewOrder {
    NewOrder {
        order: order.to_owned(),
        customer: "Ann".to_owned(),
        price: 30,
        discount: None,
        parcel: Parcel {
            weight,
            size: [20, 15, 5],
        },
        card: Some("ann-2028".to_owned()),
        gift_card: false,
    }
}

/// Runs an order of 30 with a parcel of this weight, through the shop's real steps and
/// policies, with the test's bank and carriers in place of the shop's, and gives back its
/// journey's path.
async fn path_of(
    order: &str,
    weight: i64,
    bank: TestBank,
    carrier: TestCarrier,
) -> Result<Vec<String>, Error> {
    let recorder = RecordingReporter::default();
    let instance = Orders::builder()
        .channel("email")
        .initial_data(order_of(order, weight))
        .reporter(recorder.clone())
        .fake_provider::<dyn Payments>("bank", move || Ok(provide!(bank)))
        .fake_provider::<dyn Carrier>("carriers", move || Ok(provide!(carrier)))
        .create()?;
    local_executor_for!(Orders).run(instance).await?;
    Ok(recorder.path())
}

/// Runs the same order as `path_of`, through the proposed workflow.
async fn proposed_path_of(
    order: &str,
    weight: i64,
    bank: TestBank,
    carrier: TestCarrier,
) -> Result<Vec<String>, Error> {
    let recorder = RecordingReporter::default();
    let instance = Proposed::builder()
        .initial_data(order_of(order, weight))
        .reporter(recorder.clone())
        .fake_provider::<dyn Payments>("bank", move || Ok(provide!(bank)))
        .fake_provider::<dyn Carrier>("carriers", move || Ok(provide!(carrier)))
        .create()?;
    local_executor_for!(Proposed).run(instance).await?;
    Ok(recorder.path())
}

A test runs each approved order through both, and says which paths the proposal keeps, and what it makes of the others:

Rust, from the program ch43_working_with_agents
#[test]
fn the_proposed_flow_keeps_two_approved_paths_and_changes_two() -> Result<(), Error> {
    let kept = [
        ("A-1", 2, TestBank::Charges, TestCarrier::CollectsAll),
        ("A-3", 2, TestBank::IsDown, TestCarrier::CollectsAll),
    ];
    for (order, weight, bank, carrier) in kept {
        assert_eq!(
            proposed_path_of(order, weight, bank, carrier)?,
            path_of(order, weight, bank, carrier)?
        );
    }
    assert_eq!(
        proposed_path_of("A-2", 90, TestBank::Charges, TestCarrier::CollectsAll)?,
        [
            "lookup attempt 1 succeeded",
            "price attempt 1 succeeded",
            "charge attempt 1 succeeded",
            "ship attempt 1 failed: over-parcel-limit",
            "ship given up: failure",
            "journey failed at ship: over-parcel-limit",
        ]
    );
    assert_eq!(
        proposed_path_of("A-4", 60, TestBank::Charges, TestCarrier::CollectsNone)?,
        [
            "lookup attempt 1 succeeded",
            "price attempt 1 succeeded",
            "charge attempt 1 succeeded",
            "ship attempt 1 failed: too-heavy",
            "ship given up: failure",
            "journey failed at ship: too-heavy",
        ]
    );
    Ok(())
}
Rust, the same in its asynchronous form, from the program ch43_working_with_agents_async
#[tokio::test]
async fn the_proposed_flow_keeps_two_approved_paths_and_changes_two() -> Result<(), Error> {
    let kept = [
        ("A-1", 2, TestBank::Charges, TestCarrier::CollectsAll),
        ("A-3", 2, TestBank::IsDown, TestCarrier::CollectsAll),
    ];
    for (order, weight, bank, carrier) in kept {
        assert_eq!(
            proposed_path_of(order, weight, bank, carrier).await?,
            path_of(order, weight, bank, carrier).await?
        );
    }
    assert_eq!(
        proposed_path_of("A-2", 90, TestBank::Charges, TestCarrier::CollectsAll).await?,
        [
            "lookup attempt 1 succeeded",
            "price attempt 1 succeeded",
            "charge attempt 1 succeeded",
            "ship attempt 1 failed: over-parcel-limit",
            "ship given up: failure",
            "journey failed at ship: over-parcel-limit",
        ]
    );
    assert_eq!(
        proposed_path_of("A-4", 60, TestBank::Charges, TestCarrier::CollectsNone).await?,
        [
            "lookup attempt 1 succeeded",
            "price attempt 1 succeeded",
            "charge attempt 1 succeeded",
            "ship attempt 1 failed: too-heavy",
            "ship given up: failure",
            "journey failed at ship: too-heavy",
        ]
    );
    Ok(())
}

In the asynchronous form, the pricing's run is an async fn, the proposal's roles take #[async_trait], as in chapter 26, path_of and proposed_path_of are async fn that await the journey, and the test is an async fn marked #[tokio::test], which awaits each path. The tests print:

What running the tests of ch43_working_with_agents prints, recorded in ch43_working_with_agents.txt
running 5 tests
test tests::a_charge_whose_bank_is_down_is_given_up_and_never_rescued ... ok
test tests::a_parcel_no_carrier_collects_is_tried_once_more_then_given_up ... ok
test tests::a_parcel_over_the_limit_is_given_up_after_its_first_attempt ... ok
test tests::an_order_the_bank_charges_and_the_carrier_takes_is_shipped ... ok
test tests::the_proposed_flow_keeps_two_approved_paths_and_changes_two ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

How it works

  1. A business rule changes in a step, and the paths hold. The pricing now caps the discount at half the price, and all four approved paths still pass: the step decides an amount, and the flow around it is the same. To change it, the agent read one step, which names its inputs by key and decides nothing about what comes next.
  2. A flow change shows in the paths it changes. The proposal changes the path of the parcel no carrier collects, as intended: ship given up: failure after one attempt. It also changes the path of the parcel over the limit, which nobody asked for. That order was given up by failed_shipping, in the hook it runs before a retry; with no retry budget there is no retry, so the hook never runs, and the journey now fails with over-parcel-limit instead of no-carrier-can-take-it. The test says so before any order takes that path.
  3. A path is the flow a person agreed. An approved path changes only when a person agrees to the new one. A failing path test is not a test to fix: it is a flow change waiting for that decision, here two of them.
  4. One rule for every change to a step: it decides nothing about the flow. A step that tries its work again, calls another service when the first fails, or tells anyone anything has taken a decision that belongs elsewhere, as chapter 40 showed. A reviewer, a person or an agent, checks every change to a step for that, and every change to a workflow, a policy or a provider against the paths.

The shop gives its agents this paragraph in its AGENTS.md:

Business rules live in steps; the flow lives in the workflows, their policies and their providers. A change to a step never decides anything about the flow: it does not try its work again, fall back to another service or tell anyone, and it reports what happened as an outcome. A change to a workflow, a policy or a provider is a flow change: say so in the pull request, show which approved paths it changes, and never edit an approved path to make a test pass. A person approves every new path.

This is where the book ends, for now. Its workflows run one after the other, from start to end, in one program. Later tiers of Itinera will add moving a journey to another workflow, running a workflow from inside another, and journeys that survive a crash and resume, each with a part of this book of its own.

Outcomes

We have shown how an agent changes a business rule without touching the flow, and how a proposed change to the flow shows in the paths it changes.

The usual way With Itinera
Whether a change altered the flow is often found by reading the whole diff, or in production. A business rule changes in a step, and the approved paths say whether the flow changed.
A side effect of a flow change, such as a check that no longer runs, often goes unnoticed. The paths it changes are listed by a test, for a person to approve.