car-engine 0.34.0

Core runtime engine for Common Agent Runtime
//! Live bridge for the goal loop: project [`GoalInputs`] from a [`Runtime`]'s
//! **ground truth** — its event-log receipts, its shared state, its
//! transactional consistency — so `car-verify`'s pure
//! [`car_verify::goal::evaluate_goal`] decides completion against what actually
//! happened, not a transcript read.
//!
//! See `docs/proposals/goal-loop.md`. This is the deterministic Evaluator half
//! of the goal loop. It is the seam the flagship assistant and the external
//! (Codex / Claude Code) executors plug into: each iteration, the caller runs
//! any command / model-judge checks it owns, then calls
//! [`Runtime::gather_goal_inputs`] to fold in the runtime-owned signals, then
//! runs `evaluate_goal`. The loop driver itself
//! ([`car_verify::goal::run_goal_loop`]) stays injected and crate-free.

use crate::Runtime;
use car_eventlog::tool_receipts::ToolClaim;
use car_ir::ActionProposal;
use car_verify::goal::GoalInputs;
use car_verify::plan_check::{check_plan, PlanCheckRequest};
use car_verify::transaction::check_transaction;
use std::collections::HashMap;

/// What to project into a [`GoalInputs`] for one evaluation. The
/// runtime-owned signals (`claims`, `plan`, `transaction_proposal`,
/// `state_keys`) are gathered by [`Runtime::gather_goal_inputs`]; the
/// caller-owned checks (`command_exits`, `model_verdicts`) are passed through
/// verbatim, since running a shell check or a model judge belongs to the
/// executor, not the runtime (the same injected split the driver uses).
#[derive(Debug, Clone, Default)]
pub struct GoalGather {
    /// The model's tool claims for this iteration (from its tool_calls / IR),
    /// cross-checked against the event-log receipts. Empty ⇒ nothing claimed,
    /// which is vacuously grounded.
    pub claims: Vec<ToolClaim>,
    /// Scope the receipt check to one proposal's events.
    pub proposal_id: Option<String>,
    /// A STRIPS plan to forward-check (drives `plan_achieved`).
    pub plan: Option<PlanCheckRequest>,
    /// A proposal to check for transactional consistency against current state
    /// (drives `state_consistent` — the state-drift guard).
    pub transaction_proposal: Option<ActionProposal>,
    /// State keys to include in the snapshot for `StatePredicate` conditions;
    /// empty ⇒ the whole state.
    pub state_keys: Vec<String>,
    /// Exit codes of command checks the caller already ran on the substrate.
    pub command_exits: HashMap<String, i32>,
    /// Verdicts of model judges the caller already gathered.
    pub model_verdicts: HashMap<String, bool>,
}

impl Runtime {
    /// Project a [`GoalInputs`] from this runtime's ground truth plus the
    /// caller-supplied checks. Reads receipts from the event log
    /// ([`Runtime::verify_tool_receipts`]), the live state snapshot, and — when
    /// a proposal is supplied — transactional consistency. Never fabricates a
    /// signal: a check whose input wasn't requested is left `None`, so the
    /// pure evaluator fails it closed.
    pub async fn gather_goal_inputs(&self, gather: &GoalGather) -> GoalInputs {
        // Receipts: always computed (empty claims ⇒ grounded=true, vacuously),
        // so a no-claims iteration still satisfies a `ToolReceiptsGrounded`
        // leaf rather than failing it closed for lack of input.
        let receipts_grounded = Some(
            self.verify_tool_receipts(&gather.claims, gather.proposal_id.as_deref())
                .await
                .grounded,
        );

        let plan_achieved = gather.plan.as_ref().map(|p| check_plan(p).valid);

        let full = self.state.snapshot();
        let state = if gather.state_keys.is_empty() {
            full
        } else {
            gather
                .state_keys
                .iter()
                .filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone())))
                .collect()
        };

        let state_consistent = gather.transaction_proposal.as_ref().map(|p| {
            let (values, versions) = self.state.versioned_snapshot();
            check_transaction(p, &versions, Some(&values)).consistent
        });

        GoalInputs {
            receipts_grounded,
            plan_achieved,
            state_consistent,
            state,
            command_exits: gather.command_exits.clone(),
            model_verdicts: gather.model_verdicts.clone(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ToolExecutor;
    use car_eventlog::tool_receipts::{ClaimKind, ToolClaim};
    use car_ir::{Action, ActionProposal, ActionType, FailureBehavior};
    use car_verify::goal::{evaluate_goal, GoalCondition};
    use serde_json::Value;
    use std::sync::Arc;

    struct EchoExec;
    #[async_trait::async_trait]
    impl ToolExecutor for EchoExec {
        async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
            match tool {
                "echo" => Ok(params.get("message").cloned().unwrap_or(Value::Null)),
                _ => Err(format!("unknown tool: {tool}")),
            }
        }
    }

    fn tool_call(tool: &str, params: HashMap<String, Value>) -> Action {
        Action {
            id: uuid::Uuid::new_v4().simple().to_string()[..12].to_string(),
            action_type: ActionType::ToolCall,
            tool: Some(tool.to_string()),
            parameters: params,
            preconditions: vec![],
            expected_effects: HashMap::new(),
            state_dependencies: vec![],
            read_set: vec![],
            write_set: vec![],
            assumptions: vec![],
            invocation_mode: Default::default(),
            idempotent: false,
            max_retries: 3,
            failure_behavior: FailureBehavior::Abort,
            timeout_ms: None,
            metadata: HashMap::new(),
        }
    }

    fn state_write(key: &str, value: Value) -> Action {
        Action {
            id: uuid::Uuid::new_v4().simple().to_string()[..12].to_string(),
            action_type: ActionType::StateWrite,
            tool: None,
            parameters: [
                ("key".to_string(), Value::from(key)),
                ("value".to_string(), value),
            ]
            .into(),
            preconditions: vec![],
            expected_effects: HashMap::new(),
            state_dependencies: vec![],
            read_set: vec![],
            write_set: vec![],
            assumptions: vec![],
            invocation_mode: Default::default(),
            idempotent: false,
            max_retries: 3,
            failure_behavior: FailureBehavior::Abort,
            timeout_ms: None,
            metadata: HashMap::new(),
        }
    }

    fn proposal(actions: Vec<Action>) -> ActionProposal {
        ActionProposal {
            id: "goal-test".to_string(),
            source: "test".to_string(),
            actions,
            timestamp: chrono::Utc::now(),
            context: HashMap::new(),
        }
    }

    /// End-to-end over REAL ground truth: execute a proposal that calls a tool
    /// and writes state, then gather inputs and evaluate a goal condition —
    /// proving `receipts_grounded` reads the event log and `StatePredicate`
    /// reads live state.
    #[tokio::test]
    async fn gather_reads_real_receipts_and_state() {
        let rt = Runtime::new().with_executor(Arc::new(EchoExec));
        rt.register_tool("echo").await;

        let p = proposal(vec![
            tool_call("echo", [("message".to_string(), Value::from("hi"))].into()),
            state_write("tests_passing", Value::Bool(true)),
        ]);
        let r = rt.execute(&p).await;
        assert!(r.all_succeeded(), "setup proposal must succeed");

        // A truthful claim ("I invoked echo") + the real state write.
        let gather = GoalGather {
            claims: vec![ToolClaim {
                kind: ClaimKind::Invoked,
                tool: "echo".to_string(),
                call_id: None,
                count: None,
                text: None,
            }],
            proposal_id: Some("goal-test".to_string()),
            ..Default::default()
        };
        let inputs = rt.gather_goal_inputs(&gather).await;
        assert_eq!(inputs.receipts_grounded, Some(true));
        assert_eq!(inputs.state.get("tests_passing"), Some(&Value::Bool(true)));

        let cond = GoalCondition::AllOf {
            conditions: vec![
                GoalCondition::ToolReceiptsGrounded,
                GoalCondition::StatePredicate {
                    key: "tests_passing".into(),
                    equals: Value::Bool(true),
                },
            ],
        };
        let verdict = evaluate_goal(&cond, &inputs);
        assert!(verdict.met && verdict.grounded, "{}", verdict.reason);
    }

    /// The `/goal` blindspot, closed: a fabricated tool claim makes the
    /// grounded-receipts condition fail even though the model "said" it ran.
    #[tokio::test]
    async fn fabricated_claim_fails_the_receipts_condition() {
        let rt = Runtime::new().with_executor(Arc::new(EchoExec));
        rt.register_tool("echo").await;
        let r = rt
            .execute(&proposal(vec![tool_call(
                "echo",
                [("message".to_string(), Value::from("hi"))].into(),
            )]))
            .await;
        assert!(r.all_succeeded());

        // The model claims it deployed — but no deploy ever ran.
        let gather = GoalGather {
            claims: vec![ToolClaim {
                kind: ClaimKind::Invoked,
                tool: "deploy".to_string(),
                call_id: None,
                count: None,
                text: Some("I deployed it".to_string()),
            }],
            proposal_id: Some("goal-test".to_string()),
            ..Default::default()
        };
        let inputs = rt.gather_goal_inputs(&gather).await;
        assert_eq!(inputs.receipts_grounded, Some(false));

        let verdict = evaluate_goal(&GoalCondition::ToolReceiptsGrounded, &inputs);
        assert!(
            !verdict.met,
            "a hallucinated tool claim must not satisfy the goal"
        );
        assert!(verdict.reason.contains("hallucinated"));
    }
}