agentplane 0.47.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
//! An agent whose prompt, model, result shape and ceilings come from a file.
//!
//! Run with:
//!
//! ```sh
//! cargo run --example manifest_run --features redb,fake-model,manifest
//! ```
//!
//! No API key, no network — `model::fake::FakeProvider` stands in for the provider.
//!
//! The claim this example exists to make concrete: **a builder call is invisible
//! in review, and a file is not.** Everything security-relevant here —
//! instructions, tools, model, ceilings, result contract — is in
//! `examples/triage.yaml`, where changing it is a diff with a reviewer on it.
//!
//! What it demonstrates, in order:
//!
//! 1. The prompt and schema in the file are the ones the provider is asked with.
//! 2. The ceiling in the file binds the run.
//! 3. A typo is refused rather than ignored — the failure mode that makes
//!    permissive config formats dangerous.
//! 4. A published version cannot be rewritten, and a pinned resolve refuses
//!    content the caller never reviewed.
//!
//! Point 3 is the one worth pausing on. `max_tokns: 100` in a tolerant parser
//! does not mean "a ceiling with a typo" — it means *no ceiling at all*, in the
//! one document whose purpose was to make the ceiling reviewable.

use std::sync::Arc;

use agentplane::manifest::{Manifest, ManifestError, MemoryRegistry, Registry, RegistryError};
use agentplane::model::fake::FakeProvider;
use agentplane::model::{ModelId, ModelProvider};
use agentplane::prelude::*;
use agentplane::runtime::Agent;
use serde_json::{Value, json};

/// The declaration, checked in beside the code that runs it.
const AGENT: &str = include_str!("triage.yaml");

/// One of the agent's skills.
///
/// It holds **nothing** — no manifest, no provider, no model id. An agent has
/// skills, not the other way round: the runtime is the agent, it owns the
/// declaration and the drivers, and a skill asks its execution context which
/// agent it is part of. A skill separately configured with its own copy of any
/// of that could disagree with the agent about what the agent is, which is
/// exactly the drift a single reviewable file is supposed to remove.
///
/// So nothing in this struct decides what the agent is told, which model
/// answers, or what shape the answer takes.
#[derive(Debug)]
struct Triage;

#[async_trait::async_trait]
impl Skill for Triage {
    fn descriptor(&self) -> SkillDescriptor {
        SkillDescriptor::new("triage").provides("support.triage")
    }

    async fn invoke(
        &self,
        cx: &mut StepCtx<'_>,
        input: Tainted<Value>,
    ) -> Result<Outcome, SkillError> {
        // Asked of the context, not held by this skill. Read into owned
        // values up front, so nothing borrows the agent while the effects run.
        let (system, schema) = {
            let manifest = cx.manifest().expect("this agent runs under a manifest");
            // The words the agent is given. agentplane does not own them; it
            // owns their hash, and the layout this renders to is pinned by a
            // test.
            let system = manifest
                .spec
                .identity
                .as_ref()
                .map(agentplane::manifest::Identity::system_prompt)
                .unwrap_or_default();
            (system, manifest.output_schema().cloned())
        };

        // `cx.complete_with` resolves the rest from the declaration: the
        // privileged model, its declared ceilings, the driver registered under
        // the provider's name, and the egress ceiling. The closure adds the
        // one thing the skill decides — that *this* call answers in the
        // declared result contract. Handed to `expecting`, the schema goes
        // into the **effect key**, so editing it makes a replay report
        // divergence rather than quietly reinterpreting a stored answer under
        // today's rules.
        let expecting = |schema: &Option<Value>| {
            let schema = schema.clone();
            move |mut call: agentplane::model::ModelCall| {
                if let Some(schema) = schema {
                    call = call.expecting(schema);
                }
                call
            }
        };

        let first_prompt = Tainted::object([
            ("system", Tainted::trusted(json!(system.clone()))),
            ("ticket", input),
        ]);
        // Asked twice on purpose — a verifier pass is a second question. Even
        // byte-identical calls would be fine: an effect's identity includes
        // its position in the step, so two identical dispatches are two
        // effects, each replayed from its own record.
        let first = cx.complete_with(&first_prompt, expecting(&schema)).await?;

        let draft = first.map(|completion| {
            completion
                .structured
                .unwrap_or_else(|| json!({ "text": completion.text }))
        });

        // A verifier pass over the first answer — `plan-then-execute` agents
        // routinely have one, and it is what makes the ceiling in step 2
        // demonstrable at all: a metered budget **overshoots by one operation**,
        // because a call's cost is not known until it has run. What is enforced
        // is *once consumption has reached the limit, nothing further starts*,
        // so a one-call skill completes whatever the token ceiling says.
        let checked_prompt = Tainted::object([
            ("system", Tainted::trusted(json!(system))),
            ("verify", draft),
        ]);
        let checked = cx
            .complete_with(&checked_prompt, expecting(&schema))
            .await?;

        Ok(Outcome::done(checked.map(|c| {
            c.structured.unwrap_or_else(|| json!({ "text": c.text }))
        })))
    }
}

fn runtime_for(
    store: &Arc<dyn JournalStore>,
    manifest: &Arc<Manifest>,
    provider: &Arc<FakeProvider>,
) -> Arc<Runtime> {
    let driver: Arc<dyn ModelProvider> = provider.clone();
    Runtime::builder(Arc::clone(store))
        // The driver under the name the manifest calls it — deployment wiring,
        // beside the journal, exactly where an API key would live.
        .provider("fake", driver)
        // The declaration and the skills that serve it arrive together: the
        // manifest governs *this agent's* steps — its budget, its grants, its
        // ceilings — while the runtime keeps the journal and the drivers.
        .agent(Agent::new(manifest).skill(Triage))
        .build()
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let store: Arc<dyn JournalStore> = Arc::new(RedbStore::open_in_memory()?);
    let manifest = Arc::new(Manifest::parse(AGENT)?);

    println!(
        "agent:    {} {}",
        manifest.metadata.name, manifest.metadata.version
    );
    println!("governed by: {}", manifest.digest()?.to_hex());

    // ── 1. The file's declarations reach the provider ──────────────────────
    let provider = FakeProvider::new();
    let rt = runtime_for(&store, &manifest, &provider);

    let ticket = json!({ "id": "T-4711", "body": "checkout returns 500 for EU cards" });
    let live = rt
        .run("support.triage", Tainted::trusted(ticket.clone()))
        .await?;
    println!("\n1. live run       → {:?}", live.status);
    assert_eq!(
        live.status,
        RunStatus::Succeeded,
        "the nominal manifest run failed — this example is the executable proof that a \
         declared prompt can handle untrusted input without making its system instruction \
         untrusted"
    );

    let asked = provider.asked();
    let ask = asked.first().expect("the provider was asked");
    // The instruction is a paragraph, and printing it raw breaks the column
    // this block is read in — the blank line especially, which looks like the
    // output ended.
    for (index, line) in ask.prompt["system"]
        .as_str()
        .unwrap_or("<none>")
        .lines()
        .filter(|line| !line.trim().is_empty())
        .enumerate()
    {
        let label = if index == 0 { "system prompt:" } else { "" };
        println!("   {label:<15} {line}");
    }
    println!("   schema sent:    {}", ask.schema.is_some());

    assert_eq!(
        ask.prompt["system"].as_str(),
        Some("Support ticket triage\n\nClassify severity. Never promise a refund."),
        "the provider was asked with something other than the prompt in the file, \
         which would make the manifest's digest a record of the wrong thing"
    );
    assert_eq!(
        ask.schema.as_ref(),
        manifest.output_schema(),
        "the declared result contract did not reach the provider, where the \
         constraint is enforced during generation rather than rejected afterwards"
    );
    assert_eq!(ask.model, ModelId::new("fake", "triage-1"), "wrong model");

    // ── 2. The ceiling in the file binds ───────────────────────────────────
    // Same declaration, one number changed. Nothing in this file's Rust knows
    // what the ceiling is.
    let tight = Arc::new(Manifest::parse(
        &AGENT.replace("max_tokens: 120000", "max_tokens: 1"),
    )?);
    assert_ne!(
        manifest.digest()?,
        tight.digest()?,
        "changing a ceiling must change the declaration's identity"
    );

    let provider = FakeProvider::new();
    let rt = runtime_for(&store, &tight, &provider);
    let capped = rt.run("support.triage", Tainted::trusted(ticket)).await?;
    println!("\n2. one-token ceiling → {:?}", capped.status);
    println!("   spend:          {} tokens", capped.spend().tokens);
    println!(
        "   provider calls: {} of 2 — the second never started",
        provider.calls()
    );
    assert!(
        !matches!(capped.status, RunStatus::Succeeded),
        "a ceiling declared in the manifest did not bind the run, so the file \
         documents a limit the runtime does not apply"
    );
    assert_eq!(
        provider.calls(),
        1,
        "the first call is always allowed — its cost is unknown until it runs — \
         but the second must not start once consumption has reached the limit"
    );

    // ── 3. A typo is refused, not ignored ──────────────────────────────────
    let typo = AGENT.replace("max_tokens:", "max_tokns:");
    match Manifest::parse(&typo) {
        Err(ManifestError::Syntax(detail)) => {
            println!(
                "\n3. `max_tokns:` refused → {}",
                detail.lines().next().unwrap_or(&detail)
            );
        }
        Err(e) => panic!("wrong refusal: {e}"),
        Ok(m) => panic!(
            "a typo was ignored and the token ceiling silently became {:?} — this \
             is the failure mode the format exists to prevent",
            m.budget().max_tokens
        ),
    }

    // ── 4. A version is an artifact, not a moment ──────────────────────────
    registry_refusals(&manifest).await?;

    // ── 5. The journals verify ─────────────────────────────────────────────
    for run in [live.run_id, capped.run_id] {
        store.verify(run).await?;
    }
    println!("\n5. both journals verify");

    Ok(())
}

/// A published version cannot be rewritten, and a pin does not need to trust the
/// registry.
///
/// The two are deliberately not redundant: immutability catches the honest
/// mistake at write time while trusting the registry, and a pin catches the
/// dishonest one at read time while trusting nothing.
async fn registry_refusals(manifest: &Manifest) -> Result<(), Box<dyn std::error::Error>> {
    let registry = MemoryRegistry::new();
    let published = registry.publish(manifest).await?;
    println!("\n4. published      → {}", published.to_hex());

    // A retried deploy is not an attack.
    assert_eq!(
        registry.publish(manifest).await?,
        published,
        "republishing identical content must succeed"
    );

    // The supply-chain shape: same name, same version, one more tool. Nothing a
    // version-pinned consumer would notice.
    let widened = Manifest::parse(
        &AGENT.replace("  tools:\n", "  tools:\n    - ref: \"tool://shell/exec\"\n"),
    )?;
    match registry.publish(&widened).await {
        Err(RegistryError::Immutable { .. }) => {
            println!("   rewriting 2.0.0 with a wider grant → refused");
        }
        Err(e) => panic!("wrong refusal: {e}"),
        Ok(_) => panic!("a published version was replaced with a wider grant"),
    }

    // And a pin is the half that survives the registry itself being the
    // compromised party.
    let hostile = MemoryRegistry::new();
    hostile.publish(&widened).await?;
    match hostile
        .resolve_pinned("support-triage", "2.0.0", published)
        .await
    {
        Err(RegistryError::PinBroken { .. }) => {
            println!("   pinned resolve against substituted content → refused");
        }
        Err(e) => panic!("wrong refusal: {e}"),
        Ok(_) => panic!("a pinned resolve accepted content the caller never reviewed"),
    }

    Ok(())
}