diff --git a/Cargo.lock b/Cargo.lock index 8b96d8800..50d5b636b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1991,7 +1991,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "chrono", "fabro-automation", @@ -2017,7 +2017,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2042,7 +2042,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2063,11 +2063,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "fabro-config", "fabro-types", @@ -2075,7 +2075,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2171,7 +2171,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2200,7 +2200,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2229,7 +2229,7 @@ dependencies = [ [[package]] name = "fabro-db" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2242,7 +2242,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2261,7 +2261,7 @@ dependencies = [ [[package]] name = "fabro-dot" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "fabro-template", "fabro-types", @@ -2273,7 +2273,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2287,7 +2287,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2309,7 +2309,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2334,7 +2334,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "fabro-dot", @@ -2344,7 +2344,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", @@ -2354,7 +2354,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "fabro-config", @@ -2371,7 +2371,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -2383,7 +2383,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -2408,7 +2408,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2419,7 +2419,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2448,7 +2448,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "fabro-client", @@ -2468,7 +2468,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "chrono", "fabro-db", @@ -2486,7 +2486,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2516,7 +2516,7 @@ dependencies = [ [[package]] name = "fabro-petri" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2571,7 +2571,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "libc", "tempfile", @@ -2583,7 +2583,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "aho-corasick", "pebble-coding-agent", @@ -2600,7 +2600,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2695,7 +2695,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -2716,18 +2716,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" [[package]] name = "fabro-store" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "bytes", "chrono", @@ -2755,7 +2755,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2781,7 +2781,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2795,7 +2795,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2820,7 +2820,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2844,7 +2844,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2858,7 +2858,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "chrono", "clap", @@ -2885,7 +2885,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -2908,7 +2908,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2944,7 +2944,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2989,7 +2989,7 @@ dependencies = [ [[package]] name = "fabro-workflow-version" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "fabro-config", "fabro-dot", @@ -7925,7 +7925,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index b9b7f1bb7..15fbf3259 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.386.0-nightly.0" +version = "0.387.0-nightly.0" license = "MIT" [workspace.dependencies] diff --git a/docs/public/agents/skills.mdx b/docs/public/agents/skills.mdx index cbc300b67..f850cc978 100644 --- a/docs/public/agents/skills.mdx +++ b/docs/public/agents/skills.mdx @@ -31,20 +31,24 @@ Skills can be invoked in two ways. ### Slash syntax (user input) -Users can reference a skill with a `/` prefix in their input: +On the native agent backend, start authored input with `/skill-name` to invoke a skill: ``` /commit fix the off-by-one error in pagination ``` -Fabro detects the `/commit` token, finds the matching skill, and expands the input by replacing the skill reference with the full template. The remaining text (`fix the off-by-one error in pagination`) is substituted into the `{{user_input}}` placeholder. +Fabro detects the leading `/commit` command, finds the matching skill, and expands the authored input using its template. The remaining text (`fix the off-by-one error in pagination`) is substituted into the `{{user_input}}` placeholder. A template without that placeholder replaces the authored input. Rules for slash syntax: -- The `/` must be preceded by whitespace or be at the start of the input +- The command must be at the start of authored input, after any leading whitespace - The skill name must be followed by whitespace or end of input -- Only one skill reference per input is allowed +- Later slash tokens are ordinary arguments or text: `/commit mention /review` invokes only `commit`, while `Please run /commit` invokes no skill - File paths like `/usr/bin/bash` are not matched (the character after `/` must be a lowercase letter, and the name cannot contain `/`) +For native workflow agent stages, only the authored node prompt is expanded. Workflow preambles, prior-stage context, `for_each` item data, and output contracts remain outside expansion and are preserved even when the template has no `{{user_input}}` placeholder. For example, prior-stage text mentioning `/tmp` or `/commit` stays literal. + +User-delivered follow-ups also support a leading command. Generated output-repair turns bypass expansion. ACP agents receive the assembled workflow input and own any command interpretation; one-shot prompt nodes do not expand skills. + ### use_skill tool (agent-initiated) When skills are available, Fabro adds a `use_skill` tool to the agent's toolset and lists the available skills in the system prompt: @@ -69,5 +73,5 @@ Returns the skill's template text. 1. **Discovery** -- On session startup, Fabro scans the skill directories and parses all valid `SKILL.md` files 2. **Registration** -- If any skills are found, the `use_skill` tool is registered and the skill list is added to the system prompt -3. **Expansion** -- When user input contains a `/skill-name` reference, Fabro expands it before sending to the LLM +3. **Expansion** -- On the native agent backend, a leading `/skill-name` command in authored input is expanded before sending to the LLM. Leading whitespace is allowed; later slash tokens stay literal. Workflow context and output requirements remain outside expansion, and generated output-repair turns bypass it 4. **Tool use** -- The agent can also call `use_skill` at any time to load a skill's template mid-conversation diff --git a/lib/components/fabro-petri/tests/skills.rs b/lib/components/fabro-petri/tests/skills.rs new file mode 100644 index 000000000..4ba9f5e84 --- /dev/null +++ b/lib/components/fabro-petri/tests/skills.rs @@ -0,0 +1,181 @@ +//! Skills discovered through Fabro's runtime expand only authored native input. + +mod support; + +use std::sync::{Arc, Mutex}; + +use fabro_llm::test_support::{self, ScriptedAdapter}; +use fabro_petri::check::Launch; +use fabro_petri::engine::{self, RunStatus}; +use fabro_petri::runtime::RuntimeSpec; +use lithos_llm::Client; +use lithos_llm::catalog::ProviderId; +use lithos_llm::middleware::{Call, Observer, ObserverMiddleware}; +use lithos_llm::types::{ContentPart, Request}; +use petri_store::MemoryRunStore; +use support::{Silent, no_questions, run_request}; +use tokio::fs; + +const MODEL: &str = "gpt-5.4"; +const TEMPLATE: &str = "COMMIT TEMPLATE SENTINEL"; +const PRIOR: &str = "smoke-tested via a /tmp build; reviewer mentioned /commit"; + +struct Requests(Arc>>); + +impl Observer for Requests { + fn on_start(&self, call: &Call) { + self.0 + .lock() + .expect("request capture lock") + .push(call.request().clone()); + } +} + +async fn run_native(workflow: &str, responses: &[&str]) -> Vec { + let root = tempfile::tempdir().expect("temporary runtime home"); + let home = root.path().join("fabro-home"); + let skill = home.join("skills/commit"); + fs::create_dir_all(&skill).await.expect("skill directory"); + // No placeholder: expansion must replace only the authored input. + fs::write( + skill.join("SKILL.md"), + format!("---\nname: commit\ndescription: Test commit instructions\n---\n{TEMPLATE}\n"), + ) + .await + .expect("skill template"); + let requests = Arc::new(Mutex::new(Vec::new())); + let client = Client::builder() + .catalog(test_support::test_catalog()) + .adapter( + ProviderId::new("openai"), + ScriptedAdapter::new( + responses + .iter() + .map(|text| test_support::text_response("openai", MODEL, text)) + .collect(), + ), + ) + .middleware(ObserverMiddleware::new(Requests(Arc::clone(&requests)))) + .build() + .expect("scripted model client") + .client; + let runtime = RuntimeSpec { + model_client: Some(client), + fabro_home: Some(home), + ..RuntimeSpec::default() + }; + let graphs = support::admit( + &[ + ("workflow.fabro", workflow), + ("workflow.toml", support::SETTINGS), + ], + Launch { + default_model: Some(format!("openai/{MODEL}")), + ..Launch::default() + }, + &runtime, + ); + let outcome = engine::run(run_request( + "skills", + &root.path().join("run"), + graphs, + Arc::new(MemoryRunStore::new()), + runtime, + no_questions(Arc::new(Silent)), + )) + .await + .expect("workflow run settles"); + assert_eq!(outcome.status, RunStatus::Success, "{outcome:?}"); + let requests = requests.lock().expect("request capture lock").clone(); + for request in &requests { + for message in request.messages() { + assert!( + message + .content() + .iter() + .all(|part| !matches!(part, ContentPart::Text { text } if text.is_empty())), + "absent segments must not become invalid empty text blocks: {message:?}" + ); + } + } + requests +} + +fn user_text(request: &Request) -> String { + request + .messages() + .last() + .expect("user input message") + .content() + .iter() + .filter_map(|part| match part { + ContentPart::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") +} + +#[tokio::test] +async fn authored_skill_commands_preserve_workflow_context_and_output_requirements() { + for (authored, expands) in [ + (" /commit authored arguments /tmp /commit", true), + ("Address the feedback about /commit and /tmp", false), + ] { + let workflow = format!( + r#"digraph Skills {{ + graph [backend="api", goal="Preserve workflow context", default_max_retries=0] + start [shape=Mdiamond] + prior [prompt="Report the previous result"] + task [prompt="{authored}", fidelity="summary:high", output_schema="routing", output_retries=0] + exit [shape=Msquare] + start -> prior -> task -> exit + }}"# + ); + let requests = run_native(&workflow, &[PRIOR, r#"{"outcome":"succeeded"}"#]).await; + assert_eq!(requests.len(), 2); + let sent = user_text(&requests[1]); + assert!(sent.contains("Preserve workflow context"), "{sent}"); + assert!(sent.contains(PRIOR), "{sent}"); + assert!(sent.contains("Fabro final-output contract"), "{sent}"); + assert_eq!(sent.contains(TEMPLATE), expands, "{sent}"); + if expands { + assert!(!sent.contains(authored), "{sent}"); + assert!(sent.find(PRIOR) < sent.find(TEMPLATE), "{sent}"); + assert!( + sent.find(TEMPLATE) < sent.find("Fabro final-output contract"), + "{sent}" + ); + } else { + assert!(sent.contains(authored), "{sent}"); + } + } +} + +#[tokio::test] +async fn absent_native_prompt_segments_do_not_emit_empty_model_text_blocks() { + for (fidelity, authored) in [ + ("full", "Say done"), + ("compact", "Say done"), + ("compact", ""), + ] { + let workflow = format!( + r#"digraph Segments {{ + graph [backend="api", goal="Keep the nonempty preamble", default_max_retries=0] + start [shape=Mdiamond] + task [prompt="{authored}", fidelity="{fidelity}"] + exit [shape=Msquare] + start -> task -> exit + }}"# + ); + let requests = run_native(&workflow, &["done"]).await; + assert_eq!(requests.len(), 1); + let sent = user_text(&requests[0]); + if fidelity == "full" { + assert_eq!(sent, authored); + } else { + assert!(sent.contains("Keep the nonempty preamble"), "{sent}"); + assert!(sent.contains(authored), "{sent}"); + } + } +}