agent-sdk-core 0.1.0-alpha.3

Product-neutral primitive kernel and contracts for a Rust-first Agent SDK.
Documentation
use agent_sdk_core::{
    AgentErrorKind, AgentStateMachine, CheckpointPolicy, JournalRecordKind, LoopEventKind,
    LoopState, LoopStopReason, LoopTerminalResult, LoopTerminalStatus, LoopTrigger,
    MaxIterationOutcome, RecoveryAction, RecoveryClassification, RecoveryFailureKind,
    RetryClassification, SideEffectPolicy, TransitionGuard, TransitionGuardSet, TransitionInput,
    classify_recovery, contract_state_names, transition_table,
};

#[test]
fn state_table_allows_documented_transitions() {
    let machine = AgentStateMachine;

    for rule in transition_table() {
        let output = machine
            .validate_transition(
                TransitionInput::new(rule.from_state, rule.trigger)
                    .with_guards(TransitionGuardSet::for_rule(rule)),
            )
            .expect("documented transition should validate");

        assert_eq!(output.from_state, rule.from_state);
        assert_eq!(output.trigger, rule.trigger);
        assert_eq!(output.next_state, rule.next_state);
        assert_eq!(output.guard, rule.guard);
        assert_eq!(output.events, rule.events);
        assert_eq!(output.journal_records, rule.journal_records);
        assert_eq!(output.checkpoint_policy, rule.checkpoint_policy);
        assert_eq!(output.side_effect_policy, rule.side_effect_policy);
        assert_eq!(output.terminal_result, rule.terminal_result);
        assert_eq!(output.recovery_classification, rule.recovery_classification);
    }
}

#[test]
fn state_table_matches_contract_state_snapshot_and_loop_enum() {
    let enum_names: Vec<_> = LoopState::all()
        .iter()
        .map(|state| state.contract_name())
        .collect();

    assert_eq!(enum_names, contract_state_names());
    assert_eq!(
        contract_state_names(),
        [
            "Starting",
            "ContextAssembly",
            "ProviderProjection",
            "ModelStreaming",
            "StreamIntervention",
            "ToolPlanning",
            "Approval",
            "ToolDenied",
            "ToolExecution",
            "Interrupted",
            "WaitingForResume",
            "Compaction",
            "Continue",
            "Recovery",
            "Completed",
            "Cancelled",
            "Failed",
        ]
    );
}

#[test]
fn all_nonterminal_states_have_cancel_transition() {
    for state in LoopState::all()
        .iter()
        .copied()
        .filter(|state| state.requires_cancel_transition())
    {
        let output = validate(
            state,
            LoopTrigger::CancelRequested,
            TransitionGuard::CancellationRequested,
        );

        assert_eq!(output.next_state, LoopState::Cancelled);
        assert_eq!(
            output.terminal_result,
            Some(LoopTerminalResult {
                status: LoopTerminalStatus::Cancelled,
                stop_reason: LoopStopReason::Cancelled,
            })
        );
        assert_eq!(output.checkpoint_policy, CheckpointPolicy::Terminal);
        assert_eq!(
            output.side_effect_policy,
            SideEffectPolicy::ReconcileRequired
        );
        assert!(output.events.contains(&LoopEventKind::RunCancelRequested));
        assert!(output.events.contains(&LoopEventKind::RunCancelled));
    }
}

#[test]
fn state_table_rejects_undocumented_transition() {
    let machine = AgentStateMachine;
    let error = machine
        .validate_transition(TransitionInput::new(
            LoopState::Starting,
            LoopTrigger::ToolUse,
        ))
        .expect_err("undocumented transition must fail closed");

    assert_eq!(error.kind(), AgentErrorKind::InvalidStateTransition);
    assert_eq!(error.retry(), RetryClassification::RepairNeeded);
}

#[test]
fn guards_are_required_and_typed() {
    let machine = AgentStateMachine;

    let error = machine
        .validate_transition(TransitionInput::new(
            LoopState::Starting,
            LoopTrigger::StartRun,
        ))
        .expect_err("missing typed package-valid guard must fail");

    assert_eq!(error.kind(), AgentErrorKind::InvalidStateTransition);

    let output = machine
        .validate_transition(
            TransitionInput::new(LoopState::Starting, LoopTrigger::StartRun)
                .with_guard(TransitionGuard::PackageValid),
        )
        .expect("typed guard satisfies transition");

    assert_eq!(output.next_state, LoopState::ContextAssembly);
}

#[test]
fn tool_denied_and_approval_paths_do_not_start_executor() {
    let policy_deny = validate(
        LoopState::ToolPlanning,
        LoopTrigger::PolicyDeny,
        TransitionGuard::DeniedResultAllowed,
    );

    assert_eq!(policy_deny.next_state, LoopState::ToolDenied);
    assert_eq!(
        policy_deny.events,
        vec![
            LoopEventKind::ToolApprovalRequired,
            LoopEventKind::ApprovalDenied,
        ]
    );
    assert!(!policy_deny.events.contains(&LoopEventKind::ToolStarted));
    assert_eq!(policy_deny.side_effect_policy, SideEffectPolicy::None);

    let timeout = validate(
        LoopState::Approval,
        LoopTrigger::ApprovalTimeout,
        TransitionGuard::TimeoutElapsed,
    );

    assert_eq!(timeout.next_state, LoopState::ToolDenied);
    assert_eq!(
        timeout.events,
        vec![
            LoopEventKind::ApprovalTimedOut,
            LoopEventKind::ApprovalDenied,
        ]
    );
    assert!(!timeout.events.contains(&LoopEventKind::ToolStarted));

    let continue_after_denial = validate(
        LoopState::ToolDenied,
        LoopTrigger::ContinueWithDeniedResult,
        TransitionGuard::DeniedResultAllowed,
    );
    assert_eq!(continue_after_denial.next_state, LoopState::Continue);

    let fail_after_denial = validate(
        LoopState::ToolDenied,
        LoopTrigger::FailOnDenied,
        TransitionGuard::DecisionValid,
    );
    assert_eq!(fail_after_denial.next_state, LoopState::Failed);
    assert_eq!(
        fail_after_denial.terminal_result,
        Some(LoopTerminalResult {
            status: LoopTerminalStatus::Failed,
            stop_reason: LoopStopReason::ToolDenied,
        })
    );
}

#[test]
fn cancel_from_model_stream_or_approval_never_starts_tool_execution() {
    let model_stream_cancel = validate(
        LoopState::ModelStreaming,
        LoopTrigger::CancelRequested,
        TransitionGuard::CancellationRequested,
    );

    assert_eq!(model_stream_cancel.next_state, LoopState::Cancelled);
    assert!(
        !model_stream_cancel
            .events
            .contains(&LoopEventKind::ToolStarted)
    );
    assert!(
        !model_stream_cancel
            .events
            .contains(&LoopEventKind::ToolCompleted)
    );
    assert_eq!(
        model_stream_cancel.side_effect_policy,
        SideEffectPolicy::ReconcileRequired
    );

    let approval_cancel = validate(
        LoopState::Approval,
        LoopTrigger::CancelRequested,
        TransitionGuard::CancellationRequested,
    );

    assert_eq!(approval_cancel.next_state, LoopState::Cancelled);
    assert!(!approval_cancel.events.contains(&LoopEventKind::ToolStarted));
    assert_eq!(
        approval_cancel.journal_records,
        vec![JournalRecordKind::Recovery, JournalRecordKind::Run]
    );
}

#[test]
fn max_iterations_returns_typed_stop_reason_by_policy() {
    let completed = validate(
        LoopState::Continue,
        LoopTrigger::MaxIterationsReached {
            outcome: MaxIterationOutcome::Complete,
        },
        TransitionGuard::MaxIterationBudgetExhausted,
    );

    assert_eq!(completed.next_state, LoopState::Completed);
    assert_eq!(
        completed.terminal_result,
        Some(LoopTerminalResult {
            status: LoopTerminalStatus::Completed,
            stop_reason: LoopStopReason::MaxIterations {
                outcome: MaxIterationOutcome::Complete,
            },
        })
    );

    let failed = validate(
        LoopState::Continue,
        LoopTrigger::MaxIterationsReached {
            outcome: MaxIterationOutcome::Fail,
        },
        TransitionGuard::MaxIterationBudgetExhausted,
    );

    assert_eq!(failed.next_state, LoopState::Failed);
    assert_eq!(
        failed.terminal_result,
        Some(LoopTerminalResult {
            status: LoopTerminalStatus::Failed,
            stop_reason: LoopStopReason::MaxIterations {
                outcome: MaxIterationOutcome::Fail,
            },
        })
    );
}

#[test]
fn stream_rule_abort_retries_with_new_attempt_boundary() {
    let output = validate(
        LoopState::StreamIntervention,
        LoopTrigger::StreamAbortAndRetry,
        TransitionGuard::RuleActionAllowed,
    );

    assert_eq!(output.next_state, LoopState::ProviderProjection);
    assert_eq!(
        output.side_effect_policy,
        SideEffectPolicy::IdempotentRetryAllowed
    );
    assert!(
        output
            .journal_records
            .contains(&JournalRecordKind::StreamRule)
    );
    assert!(
        output
            .journal_records
            .contains(&JournalRecordKind::ModelAttempt)
    );
}

#[test]
fn recovery_requires_repair_plan_before_mutation() {
    let machine = AgentStateMachine;
    let trigger = LoopTrigger::FailureClassified {
        classification: RecoveryClassification::RepairRequired,
    };

    let error = machine
        .validate_transition(TransitionInput::new(LoopState::Failed, trigger))
        .expect_err("recovery classification requires explicit repair-plan guard");
    assert_eq!(error.kind(), AgentErrorKind::InvalidStateTransition);

    let planned = machine
        .validate_transition(
            TransitionInput::new(LoopState::Failed, trigger)
                .with_guard(TransitionGuard::RepairPlanSafe),
        )
        .expect("safe repair plan can enter recovery");

    assert_eq!(planned.next_state, LoopState::Recovery);
    assert_eq!(
        planned.recovery_classification,
        Some(RecoveryClassification::RepairRequired)
    );
    assert_eq!(
        planned.side_effect_policy,
        SideEffectPolicy::ReconcileRequired
    );
    assert_eq!(planned.events, vec![LoopEventKind::RecoveryPlanned]);
    assert_eq!(planned.journal_records, vec![JournalRecordKind::Recovery]);

    let repaired = validate(
        LoopState::Recovery,
        LoopTrigger::RepairApplied,
        TransitionGuard::InvariantRestored,
    );
    assert_eq!(repaired.next_state, LoopState::ContextAssembly);
}

#[test]
fn recovery_classifier_names_retry_repair_and_reconcile_outcomes() {
    let retry = classify_recovery(RecoveryFailureKind::ProviderFailure);
    assert_eq!(
        retry.classification,
        RecoveryClassification::RetryableSafeStep
    );
    assert_eq!(retry.action, RecoveryAction::RetrySafeStep);
    assert!(retry.idempotent_retry_allowed);
    assert_eq!(retry.retry, RetryClassification::Retryable);

    let reconcile = classify_recovery(RecoveryFailureKind::JournalAppendAfterEffect);
    assert_eq!(
        reconcile.classification,
        RecoveryClassification::ReconcileRequired
    );
    assert_eq!(reconcile.action, RecoveryAction::ReconcilePendingSideEffect);
    assert!(reconcile.repair_plan_required);
    assert_eq!(reconcile.retry, RetryClassification::RepairNeeded);

    let host = classify_recovery(RecoveryFailureKind::HostPolicyRequired);
    assert_eq!(
        host.classification,
        RecoveryClassification::HostConfigurationRequired
    );
    assert_eq!(host.retry, RetryClassification::HostConfigurationNeeded);
}

#[test]
fn transition_validation_is_repeatable_and_side_effect_free() {
    let input = TransitionInput::new(LoopState::ProviderProjection, LoopTrigger::ProjectionReady)
        .with_guard(TransitionGuard::PackageHashesMatch);

    let first = AgentStateMachine
        .validate_transition(input.clone())
        .expect("first validation");
    let second = AgentStateMachine
        .validate_transition(input)
        .expect("second validation");

    assert_eq!(first, second);
    assert_eq!(first.next_state, LoopState::ModelStreaming);
    assert_eq!(
        first.side_effect_policy,
        SideEffectPolicy::IntentBeforeEffect
    );
}

#[test]
fn root_cargo_test_target_is_shim_only() {
    let shim = std::fs::read_to_string(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/tests/loop_state_contract.rs"
    ))
    .expect("root shim exists");

    assert!(shim.contains("#[path = \"runtime/loop_state_contract.rs\"]"));
    assert!(!shim.contains("#[test]"));
}

fn validate(
    state: LoopState,
    trigger: LoopTrigger,
    guard: TransitionGuard,
) -> agent_sdk_core::TransitionOutput {
    AgentStateMachine
        .validate_transition(TransitionInput::new(state, trigger).with_guard(guard))
        .expect("transition should validate")
}