yoagent-state 0.4.0

Durable state and lineage for long-running agents.
Documentation
use serde_json::json;
use yoagent_state::{
    ActorRef, BehaviorContext, BehaviorId, Event, EventPattern, FnBehavior, Goal, GoalId,
    GoalStatus, MemoryEventStore, NodeId, ObjectType, Pack, PackId, Policy, PolicyAction, PolicyId,
    RelationType, StateError, StateOp, Task, TaskId, TaskStatus, YoAgentRuntime, YoAgentState,
    diff_graphs,
};

#[tokio::test]
async fn records_goal_task_failure_patch_lineage() {
    let actor = ActorRef::agent("test");
    let state = YoAgentState::load(MemoryEventStore::new()).await.unwrap();

    state
        .record_goal(Goal {
            status: GoalStatus::InProgress,
            ..Goal::new(
                GoalId::new("goal_1"),
                "Improve retry reliability",
                "Timeout retry should preserve state",
                actor.clone(),
            )
        })
        .await
        .unwrap();
    state
        .record_task(Task {
            id: TaskId::new("task_1"),
            title: "Investigate retry timeout".to_string(),
            summary: "Find why retry state resets".to_string(),
            status: TaskStatus::InProgress,
            goal: Some(GoalId::new("goal_1")),
            created_by: actor.clone(),
            metadata: json!({}),
        })
        .await
        .unwrap();
    state
        .record_failure(
            actor.clone(),
            NodeId::new("failure_1"),
            "retry timeout loses state",
            "attempt count resets",
        )
        .await
        .unwrap();
    state
        .link(
            actor,
            NodeId::new("failure_1"),
            yoagent_state::REL_BLOCKS,
            NodeId::new("goal_1"),
        )
        .await
        .unwrap();

    let lineage = state.lineage(NodeId::new("goal_1")).await;
    assert_eq!(lineage.root.unwrap().kind, "goal");
    assert!(lineage.incoming.iter().any(|rel| rel.rel == "serves"));
    assert!(lineage.incoming.iter().any(|rel| rel.rel == "blocks"));
}

#[tokio::test]
async fn typed_pack_validates_nodes_and_relations() {
    let state = YoAgentState::load(MemoryEventStore::new()).await.unwrap();
    let mut runtime = YoAgentRuntime::new(state);
    runtime.register_pack(
        Pack::new(PackId::new("pack_1"), "lineage", "0.1.0")
            .add_object_type(ObjectType::new("goal").require("title"))
            .add_object_type(ObjectType::new("task").require("title"))
            .add_relation_type(
                RelationType::new("serves")
                    .from_kind("task")
                    .to_kind("goal"),
            ),
    );

    let bad = runtime
        .create_typed_node(
            ActorRef::agent("test"),
            NodeId::new("goal_bad"),
            "goal",
            json!({}),
        )
        .await;
    assert!(matches!(bad, Err(StateError::Validation(_))));

    runtime
        .create_typed_node(
            ActorRef::agent("test"),
            NodeId::new("goal_1"),
            "goal",
            json!({ "title": "Improve retry" }),
        )
        .await
        .unwrap();
    runtime
        .create_typed_node(
            ActorRef::agent("test"),
            NodeId::new("task_1"),
            "task",
            json!({ "title": "Investigate retry" }),
        )
        .await
        .unwrap();
    runtime
        .create_typed_relation(
            ActorRef::agent("test"),
            NodeId::new("task_1"),
            "serves",
            NodeId::new("goal_1"),
            json!({}),
        )
        .await
        .unwrap();
}

#[tokio::test]
async fn policy_gate_creates_approval_request() {
    let state = YoAgentState::load(MemoryEventStore::new()).await.unwrap();
    let mut runtime = YoAgentRuntime::new(state.clone());
    runtime.register_policy(Policy::require_approval(
        PolicyId::new("policy_1"),
        "Node creation requires approval",
        PolicyAction::CreateNode,
    ));

    let result = runtime
        .create_typed_node(
            ActorRef::agent("test"),
            NodeId::new("node_1"),
            "memory",
            json!({ "title": "sensitive" }),
        )
        .await;
    assert!(matches!(result, Err(StateError::PolicyDenied(_))));
    assert_eq!(
        state
            .graph()
            .await
            .nodes
            .values()
            .filter(|node| node.kind == "approval_request")
            .count(),
        1
    );
}

#[tokio::test]
async fn behavior_subscription_applies_state_ops() {
    let state = YoAgentState::load(MemoryEventStore::new()).await.unwrap();
    let mut runtime = YoAgentRuntime::new(state.clone());
    runtime.register_behavior(FnBehavior::new(
        BehaviorId::new("behavior_1"),
        EventPattern::Kind("failure.observed".to_string()),
        |_ctx: BehaviorContext, _event: Event| async move {
            Ok(vec![StateOp::CreateNode {
                id: NodeId::new("task_from_behavior"),
                kind: "task".to_string(),
                props: json!({ "title": "Investigate failure" }),
            }])
        },
    ));

    runtime
        .emit_event(Event::new(
            ActorRef::agent("test"),
            "failure.observed",
            json!({ "title": "retry failed" }),
        ))
        .await
        .unwrap();
    assert!(
        state
            .graph()
            .await
            .get_node(&NodeId::new("task_from_behavior"))
            .is_some()
    );
}

#[tokio::test]
async fn fork_and_diff_compare_projected_graphs() {
    let state = YoAgentState::load(MemoryEventStore::new()).await.unwrap();
    let first = state
        .apply_ops(
            ActorRef::agent("test"),
            vec![StateOp::CreateNode {
                id: NodeId::new("goal_1"),
                kind: "goal".to_string(),
                props: json!({ "title": "Improve retry" }),
            }],
        )
        .await
        .unwrap();
    state
        .apply_ops(
            ActorRef::agent("test"),
            vec![StateOp::CreateNode {
                id: NodeId::new("task_1"),
                kind: "task".to_string(),
                props: json!({ "title": "Investigate retry" }),
            }],
        )
        .await
        .unwrap();

    let fork = state
        .fork_at_event(yoagent_state::ForkId::new("fork_1"), Some(first))
        .await
        .unwrap();
    let diff = diff_graphs(&fork.graph, &state.graph().await);
    assert_eq!(fork.graph.nodes.len(), 1);
    assert_eq!(diff.added_nodes, vec!["task_1".to_string()]);
}