agentyk 0.1.1

Compose agents from values and run them: turn loop, event log, capabilities, MCP, multi-provider LLM drivers.
Documentation
//! Everruns-parity user hook lifecycle, end to end.

use std::sync::{Arc, Mutex};

use agentyk::{
    Agent, FnTool, Hook, HookContext, HookErrorPolicy, HookEvent, HookMatcher, HookOutcome,
    HookPayload, Message, ModelSpec, Result, SimDriver, SimTurn, ToolOutput,
};
use async_trait::async_trait;
use serde_json::json;

struct FixedHook {
    id: &'static str,
    event: HookEvent,
    outcome: HookOutcome,
    matcher: Option<HookMatcher>,
    seen: Arc<Mutex<Vec<HookPayload>>>,
    on_error: HookErrorPolicy,
}

#[async_trait]
impl Hook for FixedHook {
    fn id(&self) -> &str {
        self.id
    }

    fn event(&self) -> HookEvent {
        self.event
    }

    fn matcher(&self) -> Option<&HookMatcher> {
        self.matcher.as_ref()
    }

    fn on_error(&self) -> HookErrorPolicy {
        self.on_error
    }

    async fn run(&self, payload: &HookPayload) -> HookOutcome {
        self.seen.lock().unwrap().push(payload.clone());
        self.outcome.clone()
    }
}

fn hook(
    id: &'static str,
    event: HookEvent,
    outcome: HookOutcome,
    seen: Arc<Mutex<Vec<HookPayload>>>,
) -> FixedHook {
    FixedHook {
        id,
        event,
        outcome,
        matcher: None,
        seen,
        on_error: HookErrorPolicy::Warn,
    }
}

#[tokio::test]
async fn prompt_tool_and_turn_hooks_mutate_in_order() -> Result<()> {
    let seen = Arc::new(Mutex::new(Vec::new()));
    let observed_argument = Arc::new(Mutex::new(String::new()));
    let tool_observation = observed_argument.clone();
    let driver = SimDriver::new([
        SimTurn::tool_call("echo", json!({"text": "unsafe"})),
        SimTurn::text("done"),
    ]);

    let pre = FixedHook {
        matcher: Some(HookMatcher {
            tool_name: Some("echo".into()),
            ..HookMatcher::default()
        }),
        ..hook(
            "pre",
            HookEvent::PreToolUse,
            HookOutcome::Mutate {
                patch: json!({"arguments": {"text": "safe"}}),
                reason: Some("redacted".into()),
            },
            seen.clone(),
        )
    };
    let agent = Agent::builder()
        .model(ModelSpec::llmsim())
        .driver(driver)
        .tool(FnTool::new(
            "echo",
            "Echo text",
            json!({"type": "object"}),
            move |arguments| {
                let tool_observation = tool_observation.clone();
                async move {
                    *tool_observation.lock().unwrap() =
                        arguments["text"].as_str().unwrap().to_string();
                    ToolOutput::text("raw")
                }
            },
        ))
        .hook(hook(
            "prompt",
            HookEvent::UserPromptSubmit,
            HookOutcome::Mutate {
                patch: json!({"message": "rewritten"}),
                reason: None,
            },
            seen.clone(),
        ))
        .hook(pre)
        .hook(hook(
            "post",
            HookEvent::PostToolUse,
            HookOutcome::Mutate {
                patch: json!({"result": "filtered", "additional_context": "checked"}),
                reason: None,
            },
            seen.clone(),
        ))
        .hook(hook(
            "end",
            HookEvent::TurnEnd,
            HookOutcome::Allow,
            seen.clone(),
        ))
        .build()?;

    let mut session = agent.session();
    let result = session.run("original").await?;
    assert_eq!(result.response, "done");
    assert_eq!(*observed_argument.lock().unwrap(), "safe");
    assert_eq!(session.messages()[0], Message::user("rewritten"));

    let tool_result = session.events().await?.into_iter().find_map(|event| {
        if let agentyk::EventData::ToolCompleted { output, .. } = event.data {
            Some(output)
        } else {
            None
        }
    });
    assert_eq!(tool_result.as_deref(), Some("filtered\nchecked"));

    let events: Vec<_> = seen
        .lock()
        .unwrap()
        .iter()
        .map(|payload| payload.event)
        .collect();
    assert_eq!(
        events,
        [
            HookEvent::UserPromptSubmit,
            HookEvent::PreToolUse,
            HookEvent::PostToolUse,
            HookEvent::TurnEnd,
        ]
    );
    Ok(())
}

#[tokio::test]
async fn blockable_and_advisory_semantics_are_distinct() -> Result<()> {
    let seen = Arc::new(Mutex::new(Vec::new()));
    let agent = Agent::builder()
        .model(ModelSpec::llmsim())
        .driver(SimDriver::new([SimTurn::text("unreachable")]))
        .hook(hook(
            "start",
            HookEvent::SessionStart,
            HookOutcome::Block {
                reason: "ignored".into(),
                user_message: None,
            },
            seen.clone(),
        ))
        .hook(hook(
            "prompt",
            HookEvent::UserPromptSubmit,
            HookOutcome::Block {
                reason: "policy".into(),
                user_message: Some("not allowed".into()),
            },
            seen.clone(),
        ))
        .hook(hook(
            "end",
            HookEvent::SessionEnd,
            HookOutcome::Allow,
            seen.clone(),
        ))
        .build()?;

    let mut session = agent.session();
    let error = session.run("blocked").await.unwrap_err();
    assert!(matches!(
        error,
        agentyk::Error::HookBlocked {
            user_message: Some(_),
            ..
        }
    ));
    session.close().await?;
    session.close().await?;

    let events: Vec<_> = seen
        .lock()
        .unwrap()
        .iter()
        .map(|payload| payload.event)
        .collect();
    assert_eq!(
        events,
        [
            HookEvent::SessionStart,
            HookEvent::UserPromptSubmit,
            HookEvent::SessionEnd,
        ]
    );
    assert!(session.is_closed());
    Ok(())
}

#[tokio::test]
async fn warn_on_error_records_a_hook_warning_without_blocking() -> Result<()> {
    let seen = Arc::new(Mutex::new(Vec::new()));
    let agent = Agent::builder()
        .model(ModelSpec::llmsim())
        .driver(SimDriver::new([SimTurn::text("ok")]))
        .hook(hook(
            "broken-start",
            HookEvent::SessionStart,
            HookOutcome::Error {
                message: "executor unavailable".into(),
            },
            seen,
        ))
        .build()?;

    let mut session = agent.session();
    assert_eq!(session.run("hello").await?.response, "ok");
    let warning = session
        .events()
        .await?
        .into_iter()
        .find(|event| event.event_type == "hook.warning")
        .expect("warn-on-error is durable");
    assert_eq!(warning.turn_id, None);
    Ok(())
}

#[test]
fn matcher_on_a_non_tool_event_is_a_build_error() {
    let seen = Arc::new(Mutex::new(Vec::new()));
    let invalid = FixedHook {
        matcher: Some(HookMatcher {
            tool_name: Some("echo".into()),
            ..HookMatcher::default()
        }),
        ..hook("invalid", HookEvent::TurnEnd, HookOutcome::Allow, seen)
    };
    let result = Agent::builder()
        .model(ModelSpec::llmsim())
        .driver(SimDriver::new(std::iter::empty::<SimTurn>()))
        .hook(invalid)
        .build();
    assert!(matches!(result, Err(agentyk::Error::InvalidAgent(_))));
}

#[test]
fn hook_context_serializes_stable_ids() {
    let context = HookContext::session(agentyk::SessionId::new());
    assert!(serde_json::to_value(context).unwrap()["session_id"].is_string());
    let payload = HookPayload::new(HookEvent::SessionStart, "start", context, json!({}));
    let value = serde_json::to_value(payload).unwrap();
    assert!(value["session_id"].is_string());
    assert!(value.get("context").is_none());
    assert!(value["ts"].is_string());
}