use std::path::Path;
use yoagent::context::ExecutionLimits;
use yoagent::extension::{Budget, ClonedHooks, RunHooks, StopContext, StopDecision, TurnDecision};
use yoagent::provider::{MockProvider, ModelConfig};
use yoagent::{
Agent, AgentMessage, Content, Message, SkillSet, SubAgentTool, ToolCallRequest, ToolDecision,
};
fn root() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
}
fn read(file: &str) -> String {
std::fs::read_to_string(root().join(file)).unwrap()
}
const FILES: [&str; 2] = ["skills/yoagent/SKILL.md", "llms.txt"];
#[test]
fn the_skill_loads_with_yoagents_own_loader() {
let skills = SkillSet::load(&[root().join("skills")]).expect("skills/ loads");
let skill = skills
.skills()
.iter()
.find(|s| s.name == "yoagent")
.expect("a skill named yoagent");
assert!(skill.description.starts_with("Use when building"));
}
fn cited_paths(text: &str) -> Vec<(String, Option<String>)> {
let prefixes = [
"docs/",
"integrations/",
"skills/",
"src/",
"tests/",
"examples/",
"CHANGELOG.md",
"llms.txt",
"README",
];
let mut found = Vec::new();
for piece in text.split(['`', '(', ')', ' ', '\n', '<', '>']) {
let piece = piece.trim_end_matches(['.', ',', ';', ':']);
let (path, anchor) = match piece.split_once('#') {
Some((p, a)) => (p, Some(a.to_string())),
None => (piece, None),
};
if prefixes.iter().any(|p| path.starts_with(p)) && path.contains('.') {
found.push((path.to_string(), anchor));
}
}
found.sort();
found.dedup();
found
}
fn slug(heading: &str) -> String {
heading
.trim()
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_')
.map(|c| if c == ' ' { '-' } else { c })
.collect()
}
fn has_heading(markdown: &str, anchor: &str) -> bool {
markdown
.lines()
.filter_map(|line| line.strip_prefix('#'))
.map(|line| line.trim_start_matches('#'))
.any(|heading| slug(heading) == anchor)
}
fn not_published(path: &str) -> bool {
path.starts_with("integrations/")
|| path.starts_with("scripts/")
|| path.starts_with("docs/images/")
|| path.starts_with("docs/theme/")
}
#[test]
fn every_path_and_anchor_the_skill_and_llms_txt_cite_exists_in_the_crate() {
for file in FILES {
let text = read(file);
let cited = cited_paths(&text);
assert!(
cited.len() >= 5,
"{file}: expected many paths, got {cited:?}"
);
for (path, anchor) in cited {
assert!(
root().join(&path).exists(),
"{file} cites a missing path: {path}"
);
assert!(
!not_published(&path),
"{file} cites {path}, which the published crate does not contain: link it as a URL"
);
if let Some(anchor) = anchor {
assert!(
has_heading(&read(&path), &anchor),
"{file} cites {path}#{anchor}: no such heading"
);
}
}
for anchor in text
.split("](#")
.skip(1)
.filter_map(|s| s.split(')').next())
{
assert!(
has_heading(&text, anchor),
"{file}: no heading for #{anchor}"
);
}
}
}
fn recipe_lines(skill: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_rust = false;
for line in skill.lines() {
if line.starts_with("```") {
in_rust = line.trim() == "```rust";
continue;
}
if !in_rust {
continue;
}
let code = line.split(" //").next().unwrap_or_default().trim();
if code.is_empty() || code.starts_with("use ") || ["{", "}", "})", "});"].contains(&code) {
continue;
}
lines.push(code.to_string());
}
lines
}
#[test]
fn every_recipe_line_in_the_skill_is_compiled_here() {
let here: Vec<String> = include_str!("skill_test.rs")
.lines()
.map(|l| l.split(" //").next().unwrap_or_default().trim().to_string())
.collect();
let recipes = recipe_lines(&read("skills/yoagent/SKILL.md"));
assert!(
recipes.len() >= 20,
"found only {} recipe lines",
recipes.len()
);
let missing: Vec<_> = recipes.iter().filter(|l| !here.contains(l)).collect();
assert!(
missing.is_empty(),
"recipe lines in SKILL.md not compiled in tests/skill_test.rs: {missing:#?}"
);
}
#[derive(Clone)]
struct ProtectGuards;
#[async_trait::async_trait]
impl RunHooks for ProtectGuards {
async fn before_tool(&self, call: &ToolCallRequest<'_>) -> ToolDecision {
let path = call.args["path"].as_str().unwrap_or_default();
if path.starts_with(".github/") {
ToolDecision::Deny("CI files are off limits".into())
} else {
ToolDecision::Allow
}
}
}
#[derive(Clone)]
struct RequireTests;
#[async_trait::async_trait]
impl RunHooks for RequireTests {
async fn on_stop(&mut self, stop: &StopContext<'_>) -> StopDecision {
let ran_tests = stop.messages.iter().any(|m| match m {
AgentMessage::Llm(Message::Assistant { content, .. }) => content.iter().any(|c| {
matches!(c, Content::ToolCall { name, arguments, .. }
if name == "bash" && arguments["command"].as_str().is_some_and(|cmd| cmd.contains("cargo test")))
}),
_ => false,
});
if ran_tests {
StopDecision::Accept
} else {
StopDecision::Continue("Run `cargo test` before saying you are done.".into())
}
}
}
#[allow(dead_code, clippy::let_and_return)]
fn policy_recipe(agent: Agent) -> Agent {
let agent = agent.with_extension(ClonedHooks::new("protect-guards", ProtectGuards).required());
agent
}
#[allow(dead_code)]
fn budget_recipe(mut agent: Agent) -> Agent {
yoagent::provider::prices::enable_bundled(); let config = ModelConfig::claude_sonnet_5();
if let Some(budget) = Budget::for_model(2.00, &config) {
agent = agent.with_extension(budget.across_runs()); }
agent
}
#[allow(dead_code, clippy::let_and_return)]
fn sub_agent_recipe(agent: Agent) -> Agent {
let child = SubAgentTool::from_config("researcher", ModelConfig::claude_sonnet_5())
.with_system_prompt("Research, then report.")
.with_execution_limits(ExecutionLimits::default().with_max_turns(20));
let agent = agent
.with_tools(vec![Box::new(child)])
.with_tree_extension(ClonedHooks::new("protect-guards", ProtectGuards).required());
agent
}
#[tokio::test]
async fn the_verifier_recipe_sends_the_agent_back_until_tests_ran() {
let answer = Message::user("done");
let without: Vec<AgentMessage> = vec![AgentMessage::Llm(Message::user("fix it"))];
let stop = StopContext::new(&answer, &without, 0);
assert!(matches!(
RequireTests.on_stop(&stop).await,
StopDecision::Continue(_)
));
let call = Content::tool_call(
"c1",
"bash",
serde_json::json!({"command": "cargo test --all-features"}),
);
let with: Vec<AgentMessage> = vec![AgentMessage::Llm(Message::assistant(
vec![call],
yoagent::StopReason::ToolUse,
"m",
"p",
yoagent::Usage::default(),
))];
let stop = StopContext::new(&answer, &with, 0);
assert!(matches!(
RequireTests.on_stop(&stop).await,
StopDecision::Accept
));
let _ = (Agent::from_provider(
MockProvider::new(vec![]),
ModelConfig::mock(),
),);
}
#[allow(dead_code, clippy::let_underscore_future)]
fn prose_names() {
let _ = Agent::from_config;
let _ = |a: Agent, b: Budget| a.with_extension(b);
let _ = Agent::with_tools;
let _ = |a: Agent, b: Budget| a.with_tree_extension(b);
let _ = |a: Agent, s: std::sync::Arc<dyn yoagent::ToolSource>| a.with_tool_source(s);
#[cfg(feature = "native")]
let _ = Agent::with_mcp_server_stdio;
let _ = Agent::with_mcp_server_http;
let _ = Agent::with_mcp_server_http_transport;
async fn structured(a: &mut Agent) {
let _ = a
.prompt_structured::<serde_json::Value>("x", serde_json::json!({}))
.await;
}
let _ = structured;
let _ = Agent::steer;
let _ = Agent::follow_up;
let _ = Agent::abort;
let _ = Agent::total_cost_usd;
let _ = |p: MockProvider| Agent::from_provider(p, ModelConfig::mock());
let _ = yoagent::retry::retry_safe_events;
#[cfg(feature = "native")]
let _ = yoagent::tools::default_tools;
let _ = yoagent::session::Session::new;
let _ = TurnDecision::Note;
let _ = StopDecision::Continue;
let _ = yoagent::ToolError::Cancelled;
let _ = yoagent::agent_loop::CANCELLED_MARKER;
let _ = |t: yoagent::mcp::HttpTransport| t.with_header("x-key", "v");
let _ = |p: std::sync::Arc<dyn yoagent::provider::StreamProvider>| {
yoagent::agent_loop::AgentLoopConfig::new(p, "model")
};
}