Adopt Petri skill expansion boundaries

This commit is contained in:
Scott Werner 2026-10-14 17:26:56 -04:00
parent ac84eccfcc
commit daf82929a0
3 changed files with 205 additions and 20 deletions

30
Cargo.lock generated
View file

@ -5167,7 +5167,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petri-attractor-steps"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"globset",
@ -5198,7 +5198,7 @@ dependencies = [
[[package]]
name = "petri-driver"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"getrandom 0.3.4",
@ -5218,7 +5218,7 @@ dependencies = [
[[package]]
name = "petri-engine"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"petri-ir",
"serde",
@ -5230,7 +5230,7 @@ dependencies = [
[[package]]
name = "petri-execution"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"petri-driver",
@ -5254,7 +5254,7 @@ dependencies = [
[[package]]
name = "petri-executor"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"libc",
@ -5269,7 +5269,7 @@ dependencies = [
[[package]]
name = "petri-executor-sandbox"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"petri-executor",
@ -5291,7 +5291,7 @@ dependencies = [
[[package]]
name = "petri-frontend"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"marked-yaml",
"petri-ir",
@ -5305,7 +5305,7 @@ dependencies = [
[[package]]
name = "petri-frontend-attractor"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"minijinja",
"petri-frontend",
@ -5322,7 +5322,7 @@ dependencies = [
[[package]]
name = "petri-frontend-fabro"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"petri-frontend",
"petri-frontend-attractor",
@ -5338,7 +5338,7 @@ dependencies = [
[[package]]
name = "petri-frontend-native"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"petri-frontend",
"petri-ir",
@ -5349,7 +5349,7 @@ dependencies = [
[[package]]
name = "petri-ir"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"regex",
"serde",
@ -5362,7 +5362,7 @@ dependencies = [
[[package]]
name = "petri-runtime"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"petri-driver",
@ -5383,7 +5383,7 @@ dependencies = [
[[package]]
name = "petri-steps"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"petri-executor",
@ -5399,7 +5399,7 @@ dependencies = [
[[package]]
name = "petri-store"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"getrandom 0.3.4",
@ -5414,7 +5414,7 @@ dependencies = [
[[package]]
name = "petri-testkit"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#857a171bc984d578eeaedac574ff754431dc0002"
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#56ec3849ca693974ff778aa5ad4f9f9410d47d29"
dependencies = [
"async-trait",
"petri-driver",

View file

@ -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

View file

@ -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<Mutex<Vec<Request>>>);
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<Request> {
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::<Vec<_>>()
.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}");
}
}
}