diff --git a/CHANGELOG.md b/CHANGELOG.md index 1236a2e..5b0e79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/). ## Unreleased +### Docs + +- **A skill for agents building on yoagent**: `skills/yoagent/SKILL.md` (AgentSkills; ships in the crate) — which feature for which job, recipes compiled by `tests/skill_test.rs`, tips and pitfalls, what changed per version — and `llms.txt`, a map of every docs page. + ### yoagent-frontend (experimental, new, not published) - **One frontend protocol** (`integrations/yoagent-frontend`): a `Session` runs one agent for any number of frontends — a pi-tui terminal UI (a rutis plugin) and browsers over WebSocket — with commands in (prompt, steer, follow-up, abort, reset, quit), and out every event (text deltas merged), a run lifecycle that is always delivered (`runStart` / `runEnd` with an `outcome`: completed, aborted, rejected or error — also when the agent's task or the session's driver fails), plugins' questions (`uiRequest`: confirm, select, input, notify; the first answer that fits wins, a safe default without a frontend or in time; `uiResolved` says why it closed), `notice`s, and `closed`, and **UI plugins**: a rutis plugin offers the browser an ES module (`renderTool` for a tool's results, `mountPanel` for a side panel). `PluginHost` replaces the rutis setup (runtimes, loader, explicit per-row runtime routing; `load` waits until plugins run — though a TypeScript plugin reads as running before its async start-up finishes, so wait for the handlers you need — and drops a batch that fails). The browser's WebSocket needs the token in the URL the server prints. Example `coding_agent` (terminal or `--web`), tried live in Chrome. yoagent's core and yoagent-rutis's API do not depend on it. diff --git a/CLAUDE.md b/CLAUDE.md index bb26afc..19ab0ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,6 +213,10 @@ CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner cargo test - Native dev-dependencies (Tokio `full`, tempfile, wiremock) are under `[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]` because Cargo builds every dev-dependency for the target under test; only `--test wasm32` is built for wasm32, and a new wasm32 test belongs in that file, not in a native test target. +### Agent-facing docs + +`skills/yoagent/SKILL.md` (AgentSkills format; packaged with the crate, so it is in every user's cargo registry) maps jobs to features with tips and pitfalls; `llms.txt` maps every docs page. `tests/skill_test.rs`: the skill loads with `SkillSet`; every repository path both cite exists **in the published crate** (`integrations/` crates are linked as URLs) and every `#anchor` is a heading; every line of the skill's ```rust recipes appears in the test file, where they are compiled (a recipe edited only in the skill fails); the verifier recipe runs; `prose_names` compiles each API named only in prose. **Release PRs update the skill's "What changed" section** (and its "Written for" version line). + ### Testing All unit tests use `MockProvider` (`provider/mock.rs`) to simulate LLM responses without network. Test files are in `tests/` — `agent_test.rs`, `agent_loop_test.rs`, `tools_test.rs`. Follow the existing pattern of constructing a `MockProvider` with predetermined responses. diff --git a/README.md b/README.md index d48fa55..34a7018 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,7 @@ in [CONTRIBUTING](CONTRIBUTING.md). - **[The book](https://yologdev.github.io/yoagent/)** — concepts, guides, a page per provider, and the [architecture and module map](https://yologdev.github.io/yoagent/architecture/overview.html) ([source](docs/)) - **[Design philosophy](https://yologdev.github.io/yoagent/design-philosophy.html)** — why yoagent is a loop library, the one extension contract, and what that costs - **[API reference](https://docs.rs/yoagent)** — built with all features enabled +- **For coding agents: [the yoagent skill](skills/yoagent/SKILL.md)** — which feature for which job, tips and pitfalls, what changed per version (AgentSkills `SKILL.md`; also in the published crate) — and [`llms.txt`](llms.txt), a map of every docs page - **[CHANGELOG](CHANGELOG.md)** — every release - **[CONTRIBUTING](CONTRIBUTING.md)** — how to build, test, and send a PR diff --git a/README.zh-CN.md b/README.zh-CN.md index 3f86652..38c73da 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -252,6 +252,7 @@ yoagent 自己的预算、工具闸门和输入守卫也是这样构建的。六 - **[手册](https://yologdev.github.io/yoagent/)**——概念、指南、每个提供商一页,以及[架构和模块地图](https://yologdev.github.io/yoagent/architecture/overview.html)([源码](docs/)) - **[设计哲学](https://yologdev.github.io/yoagent/design-philosophy.zh-CN.html)**——为什么 yoagent 是循环库、那一份扩展契约,以及它的代价 - **[API 参考](https://docs.rs/yoagent)**——启用全部 feature 构建 +- **给编程 agent 用的:[yoagent skill](skills/yoagent/SKILL.md)**——什么任务用哪个功能、技巧与陷阱、每个版本的变化(AgentSkills `SKILL.md`,也随 crate 发布);以及 [`llms.txt`](llms.txt),全部文档页的索引 - **[CHANGELOG](CHANGELOG.md)**——每一次发布 - **[CONTRIBUTING](CONTRIBUTING.md)**——如何构建、测试和提交 PR diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..7ee0074 --- /dev/null +++ b/llms.txt @@ -0,0 +1,52 @@ +# yoagent + +> The agent loop for Rust: stream from 7 LLM protocols, run tools, loop until done. A loop library, not an agent: everything that changes what the loop does goes through one contract, `Extension`. For agents building on it, start with the skill: skills/yoagent/SKILL.md (which feature for which job, tips and pitfalls, what changed per version). + +Book: https://yologdev.github.io/yoagent/ · API: https://docs.rs/yoagent · Source: https://github.com/yologdev/yoagent + +## Start here + +- [Skill: which feature for which job](skills/yoagent/SKILL.md): agent-facing map, tips, pitfalls, what changed +- [Quick start](docs/getting-started/quick-start.md): an agent with tools in a few lines +- [Installation](docs/getting-started/installation.md): features (`native`, `openapi`, `gasp`, `decision`) +- [Design philosophy](docs/design-philosophy.md): what belongs in yoagent and what does not + +## Concepts + +- [The agent loop](docs/concepts/agent-loop.md): turns, steering, follow-ups, cancellation +- [Extensions](docs/concepts/extensions.md): the plug-in contract (tools, policies, notes, verifiers, events, Budget) +- [Tools](docs/concepts/tools.md): built-ins, custom tools, ToolSource +- [Sub-agents](docs/concepts/sub-agents.md): delegation, limits, shared state, spend +- [Messages and events](docs/concepts/messages-events.md): Message, AgentMessage, AgentEvent +- [Context management](docs/concepts/context-management.md): limits, compaction +- [Prompt caching](docs/concepts/prompt-caching.md) +- [Retry](docs/concepts/retry.md): backoff, ProviderRetry, retry_safe_events +- [Structured outputs](docs/concepts/structured-outputs.md) +- [Pricing](docs/concepts/pricing.md): opt-in prices, cost buckets +- [Skills](docs/concepts/skills.md): AgentSkills SKILL.md loading +- [Session trees](docs/concepts/session-trees.md) +- [Persistence](docs/concepts/persistence.md) · [GASP](docs/concepts/gasp.md) · [Telemetry](docs/concepts/telemetry.md) +- [Callbacks](docs/concepts/callbacks.md): the older hooks +- [Decision models](docs/concepts/decision-models.md): typed judgments, tool gate, input guard + +## Guides + +- [Testing your agent](docs/guides/testing.md): MockProvider +- [MCP](docs/guides/mcp.md) · [OpenAPI](docs/guides/openapi.md) +- [WebAssembly and Cloudflare Workers](docs/guides/wasm-workers.md) + +## Providers + +- [Overview](docs/providers/overview.md) · [Model presets](docs/providers/model-presets.md) +- [Anthropic](docs/providers/anthropic.md) · [OpenAI-compatible](docs/providers/openai-compat.md) · [OpenAI Responses](docs/providers/openai-responses.md) · [Azure OpenAI](docs/providers/azure-openai.md) · [Google](docs/providers/google.md) · [Bedrock](docs/providers/bedrock.md) · [OpenCode](docs/providers/opencode.md) + +## Companion crates + +- [yoagent-rutis](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis): plugins at runtime (Rust, TypeScript on Node or Bun, Python, Go); pi extensions and DeepSeek Harness plugins unchanged +- [Plugin compatibility](docs/evals/plugin-compatibility.md): measured, top 20 per ecosystem +- [yoagent-workers](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-workers): Cloudflare Workers bindings + +## Optional + +- [Architecture overview](docs/architecture/overview.md): module map +- [CHANGELOG](CHANGELOG.md) diff --git a/skills/yoagent/SKILL.md b/skills/yoagent/SKILL.md new file mode 100644 index 0000000..c4a8e8b --- /dev/null +++ b/skills/yoagent/SKILL.md @@ -0,0 +1,227 @@ +--- +name: yoagent +description: Use when building, extending or upgrading an agent on the yoagent Rust crate. Maps each job (tools, policies, verifiers, budgets, sub-agents, plugins from other ecosystems, retries, structured output, tests) to the yoagent feature for it, with tips, pitfalls and what changed per version. +--- + +# yoagent: which feature for which job + +Written for **yoagent 0.25.2** (and yoagent-rutis 0.3.0); the recipes +below are compiled by `tests/skill_test.rs`, line for line. Check what you +build against: `cargo tree -p yoagent` or `Cargo.lock`. If you are on an +older version, read [What changed](#what-changed) first. + +yoagent is a loop library. It runs the loop (stream a reply, run the tools +it asks for, feed results back, repeat) and its guarantees: retries, +cancellation, limits, compaction, cost accounting. **Everything that +changes what the loop does goes through one contract, `Extension`.** +Before writing loop code of your own, find the hook below. + +## Pick the feature + +| You want to… | Use | Read | +|---|---|---| +| build an agent | `Agent::from_config(ModelConfig::…)` (provider and API key follow the config) | `docs/getting-started/quick-start.md` | +| give it tools | `impl AgentTool`; `.with_tools(…)` (built-ins: `tools::default_tools()`) | `docs/concepts/tools.md` | +| allow / rewrite / deny tool calls | `RunHooks::before_tool` in an `Extension` | `docs/concepts/extensions.md` | +| redact or edit a tool's output | `RunHooks::after_tool` (+ `.filters_tool_output()`) | same | +| refuse an input before the run | `RunHooks::on_input` | same | +| add a note to every model request | `RunHooks::before_model` → `TurnDecision::Note` | same | +| check the final answer, send the agent back | `RunHooks::on_stop` → `StopDecision::Continue(why)` | same | +| count, log or record what happened | `RunHooks::on_event` (sync, cheap), `finish` | same | +| cap spend in dollars | `extension::Budget` | `docs/concepts/extensions.md#budget` | +| cap turns / tokens / time | `ExecutionLimits` | `docs/concepts/context-management.md` | +| delegate to a child agent | `SubAgentTool` (own model, tools, limits) | `docs/concepts/sub-agents.md` | +| apply one policy to every sub-agent | `.with_tree_extension(…)` | `docs/concepts/extensions.md` | +| offer tools that change between runs | `ToolSource` (consulted once per run) | `docs/concepts/tools.md` | +| load plugins at runtime (Rust, TS on Node/Bun, Python, Go), or pi / DeepSeek Harness plugins unchanged | the **yoagent-rutis** crate (`RutisBridge`, one `Extension`) | [yoagent-rutis README](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis) | +| use MCP servers | `.with_mcp_server_stdio(…)` / `_http(…)` / `_http_transport(…)` | `docs/guides/mcp.md` | +| get typed JSON back | `agent.prompt_structured::(text, schema)` | `docs/concepts/structured-outputs.md` | +| steer a running agent | `agent.steer(msg)` (between tool batches), `follow_up(msg)` | `docs/concepts/agent-loop.md` | +| show streamed text without retry duplicates | `retry::retry_safe_events(rx)` | `docs/concepts/retry.md` | +| branch / restore history | `session::Session` (a tree, JSONL) | `docs/concepts/session-trees.md` | +| know what it cost | `agent.total_cost_usd()`, `SessionStats` buckets | `docs/concepts/pricing.md` | +| test without a network | `MockProvider` + `Agent::from_provider` | `docs/guides/testing.md` | +| run on Cloudflare Workers | `default-features = false` (wasm32) | `docs/guides/wasm-workers.md` | + +## Recipes + +### A policy (deny, rewrite) + +```rust +use yoagent::extension::{ClonedHooks, RunHooks}; +use yoagent::{ToolCallRequest, ToolDecision}; + +#[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 + } + } +} + +let agent = agent.with_extension(ClonedHooks::new("protect-guards", ProtectGuards).required()); +``` + +`ClonedHooks` clones the hooks per run, so state starts fresh every run. +`.required()` makes a failing hook fail the run (default: advisory, the +failure is logged and that hook skipped). + +### A verifier (don't accept "done" without evidence) + +```rust +use yoagent::extension::{RunHooks, StopContext, StopDecision}; +use yoagent::{AgentMessage, Content, Message}; + +#[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()) + } + } +} +``` + +Read the evidence from `stop.messages` (the conversation so far). Don't +try to set a flag from `on_event`: it takes `&self`, and a shared +`Arc` would carry over between runs, since `ClonedHooks` +clones the `Arc`. Continues are capped per run (`max_stop_continues`, +default 3); a required extension that still continues at the cap fails the +run. + +### A dollar budget + +```rust +use yoagent::extension::Budget; + +yoagent::provider::prices::enable_bundled(); // pricing is opt-in since 0.25 +let config = ModelConfig::claude_sonnet_5(); +if let Some(budget) = Budget::for_model(2.00, &config) { // None when unpriced + agent = agent.with_extension(budget.across_runs()); // one total for the session +} +``` + +It is checked before every model call: once the spend has reached the +limit, the run stops there (one call may cross it). It counts sub-agents, +decision models and compaction too. + +### A sub-agent with its own limits, under the parent's policy + +```rust +use yoagent::context::ExecutionLimits; + +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()); +``` + +### Plugins from other ecosystems (yoagent-rutis) + +Plugins register a handler with the bridge; the bridge is one `Extension`. +pi extensions and DeepSeek Harness (DSH) plugins load unchanged through +adapters, as far as yoagent can honour them; what it cannot (UI, commands) +is ignored, and what would decide things yoagent cannot enforce is +**refused**. Measured: of the 20 most-downloaded packages, pi 12 and DSH 4 +are usable (`docs/evals/plugin-compatibility.md`). Start from the +[yoagent-rutis README](https://github.com/yologdev/yoagent/tree/main/integrations/yoagent-rutis) +and its examples. + +## Tips and pitfalls + +- **Pricing is opt-in (0.25).** Call `prices::enable_bundled()` once, + *before* building configs, on a path tests reach too (tests never run + `main`). Without it every cost is `None`, `Budget::for_model` is `None`. +- **`with_tools` replaces the list.** An agent starts with no tools; the + built-ins come from `tools::default_tools()`. `with_mcp_server_*` adds to + that list, so call `with_tools` first — a later `with_tools` drops the + MCP tools. +- **Policies fail closed.** A `before_tool` that panics, or is still + running when the run is cancelled, denies the call (a plugin policy + through yoagent-rutis also denies on an error or its timeout); an + `after_tool` that fails withholds the output. A deny is a normal tool + result the model reads and adapts to. +- **`before_tool`, `after_tool`, `on_event` take `&self`** and run + concurrently for parallel calls; keep shared state in a `Mutex`/atomic. +- **Never rewrite the system prompt per turn.** It breaks the prompt cache. + Per-turn context goes in `before_model` notes (added to the latest user + turn, never stored). +- **Cancelling**: a cancel during a model call ends that message as + `StopReason::Aborted`; a cancel while tools run or between turns appends + `[Agent stopped: cancelled]`. Either way `on_error` is not called. A + cancelled sub-agent arrives as `ToolError::Cancelled` (cancelled before + its first answer), `Sub-agent '' failed: [Agent stopped: cancelled]`, + or `Sub-agent '' failed: Cancelled` (mid-stream) — key on the cancel + token, not the text. +- **`bash` runs in its own process group (0.25.2)**: the terminal's Ctrl+C + does not reach a running command any more. A terminal host must catch + Ctrl+C and call `agent.abort()`; the cancel then kills the whole group. +- **Retries emit `AgentEvent::ProviderRetry`.** A streamed attempt that + failed was already shown; look at the next event before treating an error + `MessageEnd` as final, or use `retry_safe_events`. +- **Limits are per run** and `max_total_tokens` counts the whole prompt + every turn, so it fills faster than the visible conversation. +- **Spend is bucketed**: the agent's own, `sub_agents`, `decision`, + `compaction`; `total_cost_usd()` adds them. Unknown cost is `None`, + never zero. +- **Plugin messages are capped at 16 MiB** (rutis 0.9 closes the channel + above that). Large images in a conversation reach plugins as text. +- **Tests**: script whole multi-turn tool conversations with `MockProvider` + (`Agent::from_provider(MockProvider::new(…), ModelConfig::mock())`); it + honours cancellation, so abort and steering paths are testable. +- **The older hooks still work** (`ToolMiddleware`, input filters, + `TurnHook`, callbacks), but new work should use `Extension`: one place, + per-run state, sub-agent coverage. + +## What changed + +Newest first; the full list is `CHANGELOG.md`. + +- **0.25.2** — MCP `HttpTransport::with_header`; `bash` kills its process + group (Ctrl+C: see above). +- **0.25.1** — GASP records plugin log lines on their run + (`GaspRecorder::extension()` + `plugin_log_layer()`). +- **0.25.0** — `Extension` / `RunHooks` (the contract above), `Budget`; + pricing opt-in (`enable_bundled`); `AgentMessage::Extension` renamed + `Custom`; `ToolGate` / `InputGuard` run as extensions; encrypted + reasoning opt-in for the Responses API. **Breaking for event consumers:** + every message now gets its `MessageStart` / `MessageEnd` (a cancel during + a retry backoff emits an `Aborted` end), every `TurnStart` a `TurnEnd`, + and a cancelled run no longer runs its pending tool calls. **Breaking for + free-function users:** `AgentLoopConfig` is `#[non_exhaustive]` — build it + with `AgentLoopConfig::new(…)`; `provider::sse` is private. +- **0.24.3** — `SubAgentTool::with_execution_limits`; 503 / 529 and + in-stream overloads retried; the `[Agent stopped: cancelled]` marker. +- **0.24.2** — built-in tool sandbox fixes (path escapes, `search` flag + injection, Gemini key out of the URL, tool panics contained). +- **0.24.1** — `retry_safe_events`. +- **yoagent-rutis 0.3.0** — rutis 0.9 (Rust 1.88); Bun and Go plugins; + the 16 MiB guard. + +## More + +The book: (source `docs/`), the +design philosophy (`docs/design-philosophy.md`), the API reference +(), and `llms.txt` at the repository root for a +map of every page. diff --git a/tests/skill_test.rs b/tests/skill_test.rs new file mode 100644 index 0000000..0ccc66d --- /dev/null +++ b/tests/skill_test.rs @@ -0,0 +1,314 @@ +//! `skills/yoagent/SKILL.md` and `llms.txt` stay true: +//! - the skill loads through yoagent's own `SkillSet`; +//! - every repository path both cite exists **in the published crate** +//! (separate crates under `integrations/` must be linked as URLs), and +//! every `#anchor` they cite is a heading of its target; +//! - every line of the skill's ```rust recipes appears in this file, where +//! the recipes are compiled, so a recipe edited only in the skill fails; +//! - the API names the skill mentions only in prose exist (`prose_names`). + +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")); +} + +/// Repository paths cited in backticks or as Markdown link targets, with +/// any `#anchor`: `(path, anchor)`. +fn cited_paths(text: &str) -> Vec<(String, Option)> { + 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 +} + +/// GitHub's heading anchor: lower case, punctuation dropped, spaces to `-`. +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) +} + +/// Directories `cargo package` leaves out: separate crates and excluded paths. +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" + ); + } + } + // In-page anchors. + for anchor in text + .split("](#") + .skip(1) + .filter_map(|s| s.split(')').next()) + { + assert!( + has_heading(&text, anchor), + "{file}: no heading for #{anchor}" + ); + } + } +} + +/// The skill's recipe lines, without `use` lines, comments, blank lines +/// and lone braces: what must also be compiled below. +fn recipe_lines(skill: &str) -> Vec { + 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 = 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:#?}" + ); +} + +// ---- The skill's recipes, line for line. Compiled, not run. ---- + +#[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()) + } + } +} + +// The recipe's lines verbatim, so the `let` stays. +#[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(); // pricing is opt-in since 0.25 + let config = ModelConfig::claude_sonnet_5(); + if let Some(budget) = Budget::for_model(2.00, &config) { + // None when unpriced + agent = agent.with_extension(budget.across_runs()); // one total for the session + } + agent +} + +// The recipe's lines verbatim, so the `let` stays. +#[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 +} + +/// The verifier works: it sends the agent back until a `cargo test` call is +/// in the conversation. +#[tokio::test] +async fn the_verifier_recipe_sends_the_agent_back_until_tests_ran() { + let answer = Message::user("done"); + let without: Vec = 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 = 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(), + ),); +} + +/// API names the skill mentions only in prose: a rename breaks this. +#[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| 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::("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| { + yoagent::agent_loop::AgentLoopConfig::new(p, "model") + }; +}